@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
431 lines (377 loc) • 14.9 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { BaseSocialMediaAction, MediaFile } from '../../base/base-social.action';
import { UserInfo, LogStatus, LogError } from '@memberjunction/core';
import axios, { AxiosInstance, AxiosError } from 'axios';
import { BaseAction } from '@memberjunction/actions';
/**
* Base class for all Instagram actions.
* Handles Instagram Graph API and Basic Display API authentication and common functionality.
* Instagram uses Facebook's Graph API infrastructure.
*/
export abstract class InstagramBaseAction extends BaseSocialMediaAction {
protected get platformName(): string {
return 'Instagram';
}
protected get apiBaseUrl(): string {
return 'https://graph.facebook.com/v18.0';
}
/**
* Instagram Business Account ID (stored in CustomAttribute1)
*/
protected get instagramBusinessAccountId(): string {
return this.getCustomAttribute(1) || '';
}
/**
* Facebook Page ID (stored in CustomAttribute2) - required for Instagram Business API
*/
protected get facebookPageId(): string {
return this.getCustomAttribute(2) || '';
}
/**
* Axios instance for API calls
*/
private _axiosInstance: AxiosInstance | null = null;
/**
* Get or create axios instance with authentication
*/
protected get axios(): AxiosInstance {
if (!this._axiosInstance) {
this._axiosInstance = axios.create({
baseURL: this.apiBaseUrl,
headers: this.buildHeaders()
});
// Add response interceptor for error handling
this._axiosInstance.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 && this.isAuthError(error)) {
LogStatus('Instagram token appears invalid, attempting refresh...');
await this.refreshAccessToken();
// Retry the request with new token
error.config.headers.Authorization = `Bearer ${this.getAccessToken()}`;
return axios.request(error.config);
}
throw error;
}
);
}
return this._axiosInstance;
}
/**
* Refresh the Instagram/Facebook access token
*/
protected async refreshAccessToken(): Promise<void> {
try {
// Instagram uses Facebook's OAuth system
// Long-lived tokens need to be exchanged periodically
const currentToken = this.getAccessToken();
if (!currentToken) {
throw new Error('No access token available to refresh');
}
// Exchange for a new long-lived token
const response = await axios.get(`${this.apiBaseUrl}/oauth/access_token`, {
params: {
grant_type: 'fb_exchange_token',
client_id: this.getCustomAttribute(3), // App ID stored in CustomAttribute3
client_secret: this.getCustomAttribute(4), // App Secret stored in CustomAttribute4
fb_exchange_token: currentToken
}
});
if (response.data.access_token) {
await this.updateStoredTokens(
response.data.access_token,
undefined, // Instagram doesn't use refresh tokens
response.data.expires_in || 5184000 // Default to 60 days
);
// Reset axios instance to use new token
this._axiosInstance = null;
}
} catch (error) {
LogError('Failed to refresh Instagram access token', error);
throw new Error('Failed to refresh Instagram access token');
}
}
/**
* Make an Instagram Graph API request
*/
protected async makeInstagramRequest<T = any>(
endpoint: string,
method: 'GET' | 'POST' | 'DELETE' = 'GET',
data?: any,
params?: any
): Promise<T> {
try {
this.logApiRequest(method, `${this.apiBaseUrl}/${endpoint}`, data || params);
const response = await this.axios.request<T>({
url: endpoint,
method,
data,
params
});
this.logApiResponse(response.data);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.handleInstagramError(error);
}
throw error;
}
}
/**
* Handle Instagram-specific errors
*/
protected handleInstagramError(error: AxiosError): void {
const response = error.response;
if (!response) {
throw new Error('Network error occurred while calling Instagram API');
}
const errorData = response.data as any;
const errorMessage = errorData?.error?.message || 'Unknown Instagram API error';
const errorCode = errorData?.error?.code;
const errorSubcode = errorData?.error?.error_subcode;
// Check for rate limiting
if (errorCode === 32 || errorCode === 4 || response.status === 429) {
const retryAfter = response.headers['x-app-usage']
? this.parseAppUsage(response.headers['x-app-usage'])
: 3600; // Default to 1 hour
throw {
code: 'RATE_LIMIT',
message: 'Instagram API rate limit exceeded',
retryAfter
};
}
// Check for permission errors
if (errorCode === 10 || errorSubcode === 460) {
throw {
code: 'INSUFFICIENT_PERMISSIONS',
message: 'Insufficient permissions for this Instagram operation'
};
}
// Media errors
if (errorCode === 100 && errorMessage.toLowerCase().includes('media')) {
throw {
code: 'INVALID_MEDIA',
message: errorMessage
};
}
// Post not found
if (errorCode === 100 && errorSubcode === 33) {
throw {
code: 'POST_NOT_FOUND',
message: 'Instagram post not found'
};
}
throw {
code: 'PLATFORM_ERROR',
message: errorMessage,
details: errorData
};
}
/**
* Parse Facebook's app usage header to determine rate limit status
*/
private parseAppUsage(appUsage: string): number {
try {
const usage = JSON.parse(appUsage);
const callCount = usage.call_count || 0;
const totalTime = usage.total_time || 0;
const totalCputime = usage.total_cputime || 0;
// If any metric is above 90%, implement backoff
if (callCount > 90 || totalTime > 90 || totalCputime > 90) {
return 3600; // Wait 1 hour
}
return 0;
} catch {
return 0;
}
}
/**
* Upload media to Instagram (returns container ID)
*/
protected async uploadSingleMedia(file: MediaFile): Promise<string> {
try {
// For Instagram, media must be hosted at a public URL
// This is a simplified version - in production, you'd upload to a CDN first
const mediaUrl = await this.uploadMediaToCDN(file);
let containerParams: any = {
access_token: this.getAccessToken()
};
// Determine media type and set appropriate parameters
if (file.mimeType.startsWith('image/')) {
containerParams.image_url = mediaUrl;
// Check if it's a carousel
if (file.filename.includes('carousel')) {
containerParams.is_carousel_item = true;
}
} else if (file.mimeType.startsWith('video/')) {
containerParams.video_url = mediaUrl;
containerParams.media_type = 'REELS'; // or 'VIDEO' for feed videos
}
// Add caption if provided in metadata
const metadata = (file as any).metadata;
if (metadata?.caption) {
containerParams.caption = metadata.caption;
}
// Create media container
const response = await this.makeInstagramRequest<{ id: string }>(
`${this.instagramBusinessAccountId}/media`,
'POST',
containerParams
);
return response.id;
} catch (error) {
LogError('Failed to upload media to Instagram', error);
throw error;
}
}
/**
* Upload media to a CDN (placeholder - implement based on your CDN)
*/
private async uploadMediaToCDN(file: MediaFile): Promise<string> {
// In a real implementation, this would upload to S3, Cloudinary, etc.
// For now, throw an error indicating this needs implementation
throw new Error('Media CDN upload not implemented. Instagram requires media to be hosted at a public URL.');
}
/**
* Publish a media container
*/
protected async publishMediaContainer(containerId: string): Promise<string> {
const response = await this.makeInstagramRequest<{ id: string }>(
`${this.instagramBusinessAccountId}/media_publish`,
'POST',
{
creation_id: containerId,
access_token: this.getAccessToken()
}
);
return response.id;
}
/**
* Get insights for a media object or account
*/
protected async getInsights(
objectId: string,
metrics: string[],
period?: 'lifetime' | 'day' | 'week' | 'days_28'
): Promise<any> {
const params: any = {
metric: metrics.join(','),
access_token: this.getAccessToken()
};
if (period) {
params.period = period;
}
const response = await this.makeInstagramRequest<{ data: any[] }>(
`${objectId}/insights`,
'GET',
null,
params
);
return response.data;
}
/**
* Search for posts (limited to business account's own posts)
*/
protected async searchPosts(params: any): Promise<any[]> {
// Instagram doesn't have a general search API
// We can only search within the business account's own posts
const fields = 'id,caption,media_type,media_url,permalink,timestamp,like_count,comments_count';
let endpoint = `${this.instagramBusinessAccountId}/media`;
const queryParams: any = {
fields,
access_token: this.getAccessToken(),
limit: params.limit || 25
};
// Add date filtering if provided
if (params.startDate) {
queryParams.since = Math.floor(new Date(params.startDate).getTime() / 1000);
}
if (params.endDate) {
queryParams.until = Math.floor(new Date(params.endDate).getTime() / 1000);
}
const posts: any[] = [];
let hasNext = true;
while (hasNext && posts.length < (params.limit || 100)) {
const response = await this.makeInstagramRequest<{
data: any[];
paging?: { next: string };
}>(endpoint, 'GET', null, queryParams);
if (response.data) {
// Filter by caption if query is provided
const filtered = params.query
? response.data.filter(post =>
post.caption?.toLowerCase().includes(params.query.toLowerCase()))
: response.data;
posts.push(...filtered);
}
if (response.paging?.next) {
// Parse next URL for pagination
const nextUrl = new URL(response.paging.next);
queryParams.after = nextUrl.searchParams.get('after');
} else {
hasNext = false;
}
}
return posts.slice(0, params.limit || 100);
}
/**
* Normalize Instagram post to common format
*/
protected normalizePost(instagramPost: any): any {
return {
id: instagramPost.id,
platform: 'Instagram',
profileId: this.instagramBusinessAccountId,
content: instagramPost.caption || '',
mediaUrls: instagramPost.media_url ? [instagramPost.media_url] : [],
publishedAt: new Date(instagramPost.timestamp),
analytics: {
impressions: instagramPost.impressions_count || 0,
engagements: (instagramPost.like_count || 0) + (instagramPost.comments_count || 0),
clicks: 0, // Not available in basic metrics
shares: 0, // Instagram doesn't track shares
comments: instagramPost.comments_count || 0,
likes: instagramPost.like_count || 0,
reach: instagramPost.reach || 0,
saves: instagramPost.saved || 0,
videoViews: instagramPost.video_views || 0,
platformMetrics: instagramPost
},
platformSpecificData: {
mediaType: instagramPost.media_type,
permalink: instagramPost.permalink,
isCarousel: instagramPost.media_type === 'CAROUSEL_ALBUM'
}
};
}
/**
* Check if media container is ready for publishing
*/
protected async isMediaContainerReady(containerId: string): Promise<boolean> {
const response = await this.makeInstagramRequest<{ status_code: string }>(
containerId,
'GET',
null,
{
fields: 'status_code',
access_token: this.getAccessToken()
}
);
return response.status_code === 'FINISHED';
}
/**
* Wait for media container to be ready
*/
protected async waitForMediaContainer(containerId: string, maxWaitTime: number = 60000): Promise<void> {
const startTime = Date.now();
const pollInterval = 2000; // 2 seconds
while (Date.now() - startTime < maxWaitTime) {
if (await this.isMediaContainerReady(containerId)) {
return;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error('Media container processing timeout');
}
}