UNPKG

@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
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. */ @RegisterClass(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'; } }