@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
491 lines (434 loc) • 16.5 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { BaseSocialMediaAction, SocialPost, SocialAnalytics, MediaFile } from '../../base/base-social.action';
import { UserInfo, LogError, LogStatus } from '@memberjunction/core';
import axios, { AxiosInstance, AxiosError } from 'axios';
import FormData from 'form-data';
import { BaseAction } from '@memberjunction/actions';
/**
* Base class for all Buffer social media actions.
* Handles Buffer-specific authentication and API interaction patterns.
*/
(BaseAction, 'BufferBaseAction')
export abstract class BufferBaseAction extends BaseSocialMediaAction {
protected get platformName(): string {
return 'Buffer';
}
protected get apiBaseUrl(): string {
return 'https://api.bufferapp.com/1';
}
private axiosInstance: AxiosInstance | null = null;
/**
* Get axios instance with authentication
*/
protected getAxiosInstance(): AxiosInstance {
if (!this.axiosInstance) {
this.axiosInstance = axios.create({
baseURL: this.apiBaseUrl,
timeout: 30000,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
// Add request interceptor for auth
this.axiosInstance.interceptors.request.use(
(config) => {
const token = this.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add response interceptor for rate limiting
this.axiosInstance.interceptors.response.use(
(response) => {
// Log rate limit headers if present
const headers = response.headers;
const rateLimitInfo = this.parseRateLimitHeaders(headers);
if (rateLimitInfo) {
LogStatus(`Buffer API - Remaining requests: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}, Reset: ${rateLimitInfo.reset}`);
}
return response;
},
async (error: AxiosError) => {
if (error.response?.status === 429) {
// Rate limit hit
const retryAfter = error.response.headers['retry-after'];
await this.handleRateLimit(retryAfter ? parseInt(retryAfter) : 60);
// Retry the request
return this.axiosInstance!.request(error.config!);
}
return Promise.reject(error);
}
);
}
return this.axiosInstance;
}
/**
* Refresh access token using refresh token
*/
protected async refreshAccessToken(): Promise<void> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available for Buffer');
}
try {
const response = await axios.post(`${this.apiBaseUrl}/oauth2/token`, {
client_id: this.getCustomAttribute(1), // Store Buffer client ID in CustomAttribute1
client_secret: this.getCustomAttribute(2), // Store Buffer client secret in CustomAttribute2
refresh_token: refreshToken,
grant_type: 'refresh_token'
});
const { access_token, expires_in } = response.data;
await this.updateStoredTokens(
access_token,
refreshToken, // Buffer doesn't rotate refresh tokens
expires_in
);
LogStatus('Buffer access token refreshed successfully');
} catch (error) {
LogError('Failed to refresh Buffer access token:', error);
throw new Error('Failed to refresh Buffer access token');
}
}
/**
* Get Buffer profiles for the authenticated user
*/
protected async getProfiles(): Promise<any[]> {
try {
const response = await this.getAxiosInstance().get('/profiles.json');
return response.data || [];
} catch (error) {
LogError('Failed to get Buffer profiles:', error);
throw error;
}
}
/**
* Upload media to Buffer
*/
protected async uploadSingleMedia(file: MediaFile): Promise<string> {
try {
const formData = new FormData();
// Convert base64 to buffer if needed
const buffer = typeof file.data === 'string'
? Buffer.from(file.data, 'base64')
: file.data;
formData.append('media', buffer, {
filename: file.filename,
contentType: file.mimeType
});
const response = await this.getAxiosInstance().post('/updates/media/upload.json', formData, {
headers: {
...formData.getHeaders()
}
});
if (response.data && response.data.media && response.data.media[0]) {
return response.data.media[0].picture; // URL of uploaded media
}
throw new Error('Failed to upload media to Buffer');
} catch (error) {
LogError('Buffer media upload failed:', error);
throw error;
}
}
/**
* Create a Buffer update (post)
*/
protected async createUpdate(
profileIds: string[],
text: string,
media?: { link?: string; description?: string; picture?: string }[],
scheduledAt?: Date,
options?: {
shorten?: boolean;
now?: boolean;
top?: boolean;
attachment?: boolean;
}
): Promise<any> {
const data: any = {
profile_ids: profileIds,
text: text,
shorten: options?.shorten !== false // Default to true
};
if (media && media.length > 0) {
data.media = media;
}
if (scheduledAt) {
data.scheduled_at = Math.floor(scheduledAt.getTime() / 1000); // Unix timestamp
} else if (options?.now) {
data.now = true;
}
if (options?.top) {
data.top = true;
}
if (options?.attachment !== undefined) {
data.attachment = options.attachment;
}
try {
const response = await this.getAxiosInstance().post('/updates/create.json', data);
return response.data;
} catch (error) {
LogError('Failed to create Buffer update:', error);
throw error;
}
}
/**
* Get updates (posts) from Buffer
*/
protected async getUpdates(
profileId: string,
status: 'pending' | 'sent',
options?: {
page?: number;
count?: number;
since?: Date;
utc?: boolean;
}
): Promise<any> {
const params: any = {
page: options?.page || 1,
count: options?.count || 10,
utc: options?.utc !== false // Default to true
};
if (options?.since) {
params.since = Math.floor(options.since.getTime() / 1000);
}
try {
const response = await this.getAxiosInstance().get(
`/profiles/${profileId}/updates/${status}.json`,
{ params }
);
return response.data;
} catch (error) {
LogError(`Failed to get ${status} updates from Buffer:`, error);
throw error;
}
}
/**
* Delete a Buffer update
*/
protected async deleteUpdate(updateId: string): Promise<boolean> {
try {
const response = await this.getAxiosInstance().post(`/updates/${updateId}/destroy.json`);
return response.data.success === true;
} catch (error) {
LogError('Failed to delete Buffer update:', error);
throw error;
}
}
/**
* Reorder updates in the queue
*/
protected async reorderUpdates(profileId: string, updateIds: string[], offset?: number): Promise<any> {
const data: any = {
order: updateIds
};
if (offset !== undefined) {
data.offset = offset;
}
try {
const response = await this.getAxiosInstance().post(
`/profiles/${profileId}/updates/reorder.json`,
data
);
return response.data;
} catch (error) {
LogError('Failed to reorder Buffer updates:', error);
throw error;
}
}
/**
* Get analytics for sent posts
*/
protected async getAnalytics(updateId: string): Promise<any> {
try {
const response = await this.getAxiosInstance().get(`/updates/${updateId}/interactions.json`);
return response.data;
} catch (error) {
LogError('Failed to get Buffer analytics:', error);
throw error;
}
}
/**
* Search posts implementation for Buffer
* Buffer doesn't have a native search API, so we'll fetch posts and filter client-side
*/
protected async searchPosts(params: {
query?: string;
hashtags?: string[];
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
profileIds?: string[];
}): Promise<SocialPost[]> {
const posts: SocialPost[] = [];
const profileIds = params.profileIds || [];
// If no profile IDs provided, get all profiles
if (profileIds.length === 0) {
const profiles = await this.getProfiles();
profileIds.push(...profiles.map(p => p.id));
}
// Fetch sent posts from each profile
for (const profileId of profileIds) {
try {
let page = 1;
let hasMore = true;
while (hasMore && posts.length < (params.limit || 100)) {
const result = await this.getUpdates(profileId, 'sent', {
page: page,
count: 100,
since: params.startDate
});
if (result.updates && result.updates.length > 0) {
for (const update of result.updates) {
const post = this.normalizePost(update);
// Apply filters
if (this.matchesSearchCriteria(post, params)) {
posts.push(post);
if (posts.length >= (params.limit || 100)) {
hasMore = false;
break;
}
}
}
page++;
hasMore = result.updates.length === 100;
} else {
hasMore = false;
}
}
} catch (error) {
LogError(`Failed to search posts for profile ${profileId}:`, error);
}
}
// Sort by published date descending
posts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
// Apply offset if specified
if (params.offset) {
return posts.slice(params.offset, params.offset + (params.limit || 100));
}
return posts.slice(0, params.limit || 100);
}
/**
* Check if a post matches search criteria
*/
private matchesSearchCriteria(post: SocialPost, params: any): boolean {
// Check date range
if (params.endDate && post.publishedAt > params.endDate) {
return false;
}
// Check query text
if (params.query) {
const query = params.query.toLowerCase();
const content = post.content.toLowerCase();
if (!content.includes(query)) {
return false;
}
}
// Check hashtags
if (params.hashtags && params.hashtags.length > 0) {
const postHashtags = this.extractHashtags(post.content);
const hasMatchingHashtag = params.hashtags.some(tag =>
postHashtags.includes(tag.toLowerCase().replace('#', ''))
);
if (!hasMatchingHashtag) {
return false;
}
}
return true;
}
/**
* Extract hashtags from post content
*/
protected extractHashtags(content: string): string[] {
const regex = /#(\w+)/g;
const hashtags: string[] = [];
let match;
while ((match = regex.exec(content)) !== null) {
hashtags.push(match[1].toLowerCase());
}
return hashtags;
}
/**
* Normalize Buffer post to common format
*/
protected normalizePost(bufferPost: any): SocialPost {
const media: string[] = [];
if (bufferPost.media) {
if (bufferPost.media.picture) {
media.push(bufferPost.media.picture);
}
if (bufferPost.media.link) {
media.push(bufferPost.media.link);
}
}
return {
id: bufferPost.id,
platform: 'Buffer',
profileId: bufferPost.profile_id,
content: bufferPost.text || '',
mediaUrls: media,
publishedAt: new Date(bufferPost.sent_at * 1000), // Convert Unix timestamp
scheduledFor: bufferPost.due_at ? new Date(bufferPost.due_at * 1000) : undefined,
analytics: bufferPost.statistics ? this.normalizeAnalytics(bufferPost.statistics) : undefined,
platformSpecificData: {
profileService: bufferPost.profile_service,
status: bufferPost.status,
userId: bufferPost.user_id,
viaName: bufferPost.via,
sourceUrl: bufferPost.source_url,
day: bufferPost.day,
dueTime: bufferPost.due_time,
mediaDescription: bufferPost.media?.description,
mediaTitle: bufferPost.media?.title,
mediaLink: bufferPost.media?.link
}
};
}
/**
* Normalize Buffer analytics to common format
*/
protected normalizeAnalytics(bufferStats: any): SocialAnalytics {
return {
impressions: bufferStats.reach || 0,
engagements: (bufferStats.clicks || 0) + (bufferStats.favorites || 0) +
(bufferStats.mentions || 0) + (bufferStats.retweets || 0) +
(bufferStats.shares || 0) + (bufferStats.comments || 0),
clicks: bufferStats.clicks || 0,
shares: bufferStats.shares || bufferStats.retweets || 0,
comments: bufferStats.comments || bufferStats.mentions || 0,
likes: bufferStats.favorites || bufferStats.likes || 0,
reach: bufferStats.reach || 0,
saves: undefined,
videoViews: undefined,
platformMetrics: bufferStats
};
}
/**
* Map Buffer error to our error codes
*/
protected mapBufferError(error: any): string {
if (error.response) {
const status = error.response.status;
const data = error.response.data;
if (status === 401) {
return 'INVALID_TOKEN';
} else if (status === 429) {
return 'RATE_LIMIT';
} else if (status === 403) {
return 'INSUFFICIENT_PERMISSIONS';
} else if (status === 404) {
return 'POST_NOT_FOUND';
} else if (data?.error?.includes('media')) {
return 'INVALID_MEDIA';
}
}
return 'PLATFORM_ERROR';
}
}