UNPKG

@memberjunction/actions-bizapps-social

Version:

Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer

630 lines (561 loc) 21.9 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction, MediaFile, SocialPost, SearchParams, SocialAnalytics } from '../../base/base-social.action'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { ActionParam } from '@memberjunction/actions-base'; import { LogStatus, LogError } from '@memberjunction/core'; import FormData from 'form-data'; import { BaseAction } from '@memberjunction/actions'; /** * Base class for all Twitter/X actions. * Handles Twitter-specific authentication, API interactions, and rate limiting. * Uses Twitter API v2 with OAuth 2.0. */ @RegisterClass(BaseAction, 'TwitterBaseAction') export abstract class TwitterBaseAction extends BaseSocialMediaAction { protected get platformName(): string { return 'Twitter'; } protected get apiBaseUrl(): string { return 'https://api.twitter.com/2'; } /** * Upload endpoint for media */ protected get uploadApiUrl(): string { return 'https://upload.twitter.com/1.1'; } /** * 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) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); // Add response interceptor for rate limit handling this._axiosInstance.interceptors.response.use( (response) => { // Log rate limit info const rateLimitInfo = this.parseRateLimitHeaders(response.headers); if (rateLimitInfo) { LogStatus(`Twitter Rate Limit - Remaining: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}, Reset: ${rateLimitInfo.reset}`); } return response; }, async (error: AxiosError) => { if (error.response?.status === 429) { // Rate limit exceeded const resetTime = error.response.headers['x-rate-limit-reset']; const waitTime = resetTime ? Math.max(0, parseInt(resetTime) - Math.floor(Date.now() / 1000)) : 60; await this.handleRateLimit(waitTime); // Retry the request return this._axiosInstance!.request(error.config!); } return Promise.reject(error); } ); } return this._axiosInstance; } /** * Refresh the access token using the refresh token */ protected async refreshAccessToken(): Promise<void> { const refreshToken = this.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available for Twitter'); } try { const clientId = this.getCustomAttribute(2) || ''; // Client ID stored in CustomAttribute2 const clientSecret = this.getCustomAttribute(3) || ''; // Client Secret stored in CustomAttribute3 const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); const response = await axios.post('https://api.twitter.com/2/oauth2/token', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `Basic ${basicAuth}` } } ); const { access_token, refresh_token: newRefreshToken, expires_in } = response.data; // Update stored tokens await this.updateStoredTokens( access_token, newRefreshToken || refreshToken, expires_in ); LogStatus('Twitter access token refreshed successfully'); } catch (error) { LogError(`Failed to refresh Twitter access token: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get the authenticated user's info */ protected async getCurrentUser(): Promise<TwitterUser> { try { const response = await this.axiosInstance.get('/users/me', { params: { 'user.fields': 'id,name,username,profile_image_url,description,created_at,verified' } }); return response.data.data; } catch (error) { LogError(`Failed to get current user: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Upload media to Twitter */ protected async uploadSingleMedia(file: MediaFile): Promise<string> { try { const fileData = typeof file.data === 'string' ? Buffer.from(file.data, 'base64') : file.data; // Step 1: Initialize upload const initResponse = await axios.post( `${this.uploadApiUrl}/media/upload.json`, new URLSearchParams({ command: 'INIT', total_bytes: fileData.length.toString(), media_type: file.mimeType, media_category: this.getMediaCategory(file.mimeType) }).toString(), { headers: { 'Authorization': `Bearer ${this.getAccessToken()}`, 'Content-Type': 'application/x-www-form-urlencoded' } } ); const mediaId = initResponse.data.media_id_string; // Step 2: Upload chunks (for large files, Twitter requires chunking) const chunkSize = 5 * 1024 * 1024; // 5MB chunks let segmentIndex = 0; for (let offset = 0; offset < fileData.length; offset += chunkSize) { const chunk = fileData.slice(offset, Math.min(offset + chunkSize, fileData.length)); const formData = new FormData(); formData.append('command', 'APPEND'); formData.append('media_id', mediaId); formData.append('segment_index', segmentIndex.toString()); formData.append('media', chunk, { filename: file.filename, contentType: file.mimeType }); await axios.post( `${this.uploadApiUrl}/media/upload.json`, formData, { headers: { 'Authorization': `Bearer ${this.getAccessToken()}`, ...formData.getHeaders() } } ); segmentIndex++; } // Step 3: Finalize upload await axios.post( `${this.uploadApiUrl}/media/upload.json`, new URLSearchParams({ command: 'FINALIZE', media_id: mediaId }).toString(), { headers: { 'Authorization': `Bearer ${this.getAccessToken()}`, 'Content-Type': 'application/x-www-form-urlencoded' } } ); // Step 4: Check processing status (for videos) if (file.mimeType.startsWith('video/')) { await this.waitForMediaProcessing(mediaId); } return mediaId; } catch (error) { LogError(`Failed to upload media to Twitter: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Wait for media processing to complete (for videos) */ private async waitForMediaProcessing(mediaId: string, maxWaitTime: number = 60000): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const response = await axios.get( `${this.uploadApiUrl}/media/upload.json`, { params: { command: 'STATUS', media_id: mediaId }, headers: { 'Authorization': `Bearer ${this.getAccessToken()}` } } ); const { processing_info } = response.data; if (!processing_info) { // Processing complete return; } if (processing_info.state === 'succeeded') { return; } if (processing_info.state === 'failed') { throw new Error(`Media processing failed: ${processing_info.error?.message || 'Unknown error'}`); } // Wait before checking again const checkAfterSecs = processing_info.check_after_secs || 1; await new Promise(resolve => setTimeout(resolve, checkAfterSecs * 1000)); } throw new Error('Media processing timeout'); } /** * Get media category based on MIME type */ private getMediaCategory(mimeType: string): string { if (mimeType.startsWith('image/gif')) { return 'tweet_gif'; } else if (mimeType.startsWith('image/')) { return 'tweet_image'; } else if (mimeType.startsWith('video/')) { return 'tweet_video'; } return 'tweet_image'; } /** * Validate media file meets Twitter requirements */ protected validateMediaFile(file: MediaFile): void { const supportedTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4' ]; if (!supportedTypes.includes(file.mimeType)) { throw new Error(`Unsupported media type: ${file.mimeType}. Supported types: ${supportedTypes.join(', ')}`); } // Twitter media size limits let maxSize: number; if (file.mimeType === 'image/gif') { maxSize = 15 * 1024 * 1024; // 15MB for GIFs } else if (file.mimeType.startsWith('image/')) { maxSize = 5 * 1024 * 1024; // 5MB for images } else if (file.mimeType.startsWith('video/')) { maxSize = 512 * 1024 * 1024; // 512MB for videos } else { maxSize = 5 * 1024 * 1024; // Default 5MB } if (file.size > maxSize) { throw new Error(`File size exceeds limit. Max: ${maxSize / 1024 / 1024}MB, Got: ${file.size / 1024 / 1024}MB`); } } /** * Create a tweet */ protected async createTweet(tweetData: CreateTweetData): Promise<Tweet> { try { const response = await this.axiosInstance.post('/tweets', tweetData); return response.data.data; } catch (error) { this.handleTwitterError(error as AxiosError); } } /** * Delete a tweet */ protected async deleteTweet(tweetId: string): Promise<void> { try { await this.axiosInstance.delete(`/tweets/${tweetId}`); } catch (error) { this.handleTwitterError(error as AxiosError); } } /** * Get tweets with specified parameters */ protected async getTweets(endpoint: string, params: Record<string, any> = {}): Promise<Tweet[]> { try { const defaultParams = { 'tweet.fields': 'id,text,created_at,author_id,conversation_id,public_metrics,attachments,entities,referenced_tweets', 'user.fields': 'id,name,username,profile_image_url', 'media.fields': 'url,preview_image_url,type,width,height', 'expansions': 'author_id,attachments.media_keys,referenced_tweets.id', 'max_results': 100 }; const response = await this.axiosInstance.get(endpoint, { params: { ...defaultParams, ...params } }); return response.data.data || []; } catch (error) { LogError(`Failed to get tweets: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get paginated tweets */ protected async getPaginatedTweets(endpoint: string, params: Record<string, any> = {}, maxResults?: number): Promise<Tweet[]> { const tweets: Tweet[] = []; let paginationToken: string | undefined; const limit = params.max_results || 100; while (true) { const response = await this.axiosInstance.get(endpoint, { params: { ...params, max_results: limit, ...(paginationToken && { pagination_token: paginationToken }) } }); if (response.data.data && Array.isArray(response.data.data)) { tweets.push(...response.data.data); } // Check if we've reached max results if (maxResults && tweets.length >= maxResults) { return tweets.slice(0, maxResults); } // Check for more pages paginationToken = response.data.meta?.next_token; if (!paginationToken) { break; } } return tweets; } /** * Convert Twitter tweet to common format */ protected normalizePost(tweet: Tweet): SocialPost { return { id: tweet.id, platform: 'Twitter', profileId: tweet.author_id || '', content: tweet.text, mediaUrls: tweet.attachments?.media_keys || [], publishedAt: new Date(tweet.created_at), analytics: tweet.public_metrics ? { impressions: tweet.public_metrics.impression_count || 0, engagements: (tweet.public_metrics.retweet_count || 0) + (tweet.public_metrics.reply_count || 0) + (tweet.public_metrics.like_count || 0) + (tweet.public_metrics.quote_count || 0), clicks: 0, // Not available in public metrics shares: tweet.public_metrics.retweet_count || 0, comments: tweet.public_metrics.reply_count || 0, likes: tweet.public_metrics.like_count || 0, reach: tweet.public_metrics.impression_count || 0, platformMetrics: tweet.public_metrics } : undefined, platformSpecificData: { conversationId: tweet.conversation_id, referencedTweets: tweet.referenced_tweets, entities: tweet.entities } }; } /** * Normalize Twitter analytics to common format */ protected normalizeAnalytics(twitterMetrics: TwitterMetrics): SocialAnalytics { return { impressions: twitterMetrics.impression_count || 0, engagements: twitterMetrics.engagement_count || 0, clicks: twitterMetrics.url_link_clicks || 0, shares: twitterMetrics.retweet_count || 0, comments: twitterMetrics.reply_count || 0, likes: twitterMetrics.like_count || 0, reach: twitterMetrics.impression_count || 0, videoViews: twitterMetrics.video_view_count, platformMetrics: twitterMetrics }; } /** * Search for tweets - implemented in search action */ protected async searchPosts(params: SearchParams): Promise<SocialPost[]> { // This is implemented in the search-tweets.action.ts throw new Error('Search posts is implemented in TwitterSearchTweetsAction'); } /** * Handle Twitter-specific errors */ protected handleTwitterError(error: AxiosError): never { if (error.response) { const { status, data } = error.response; const errorData = data as any; switch (status) { case 400: throw new Error(`Bad Request: ${errorData.detail || errorData.message || 'Invalid request parameters'}`); case 401: throw new Error('Unauthorized: Invalid or expired access token'); case 403: throw new Error('Forbidden: Insufficient permissions. Ensure the app has required Twitter scopes.'); case 404: throw new Error('Not Found: Resource does not exist'); case 429: throw new Error('Rate Limit Exceeded: Too many requests'); case 500: throw new Error('Internal Server Error: Twitter service error'); case 503: throw new Error('Service Unavailable: Twitter service temporarily unavailable'); default: throw new Error(`Twitter API Error (${status}): ${errorData.detail || errorData.message || 'Unknown error'}`); } } else if (error.request) { throw new Error('Network Error: No response from Twitter'); } else { throw new Error(`Request Error: ${error.message}`); } } /** * Parse Twitter-specific rate limit headers */ protected parseRateLimitHeaders(headers: any): { remaining: number; reset: Date; limit: number; } | null { const remaining = headers['x-rate-limit-remaining']; const reset = headers['x-rate-limit-reset']; const limit = headers['x-rate-limit-limit']; if (remaining !== undefined && reset && limit) { return { remaining: parseInt(remaining), reset: new Date(parseInt(reset) * 1000), // Unix timestamp to Date limit: parseInt(limit) }; } return null; } /** * Build search query with operators */ protected buildSearchQuery(params: SearchParams): string { const parts: string[] = []; if (params.query) { parts.push(params.query); } if (params.hashtags && params.hashtags.length > 0) { const hashtagQuery = params.hashtags .map(tag => tag.startsWith('#') ? tag : `#${tag}`) .join(' OR '); parts.push(`(${hashtagQuery})`); } return parts.join(' '); } /** * Format date for Twitter API (RFC 3339) */ protected formatTwitterDate(date: Date | string): string { if (typeof date === 'string') { date = new Date(date); } return date.toISOString(); } } /** * Twitter-specific interfaces */ export interface TwitterUser { id: string; name: string; username: string; profile_image_url?: string; description?: string; created_at: string; verified?: boolean; } export interface CreateTweetData { text: string; media?: { media_ids: string[]; }; poll?: { options: string[]; duration_minutes: number; }; reply?: { in_reply_to_tweet_id: string; }; quote_tweet_id?: string; } export interface Tweet { id: string; text: string; created_at: string; author_id?: string; conversation_id?: string; public_metrics?: { retweet_count: number; reply_count: number; like_count: number; quote_count: number; bookmark_count: number; impression_count: number; }; attachments?: { media_keys?: string[]; poll_ids?: string[]; }; entities?: { hashtags?: Array<{ start: number; end: number; tag: string }>; mentions?: Array<{ start: number; end: number; username: string }>; urls?: Array<{ start: number; end: number; url: string; expanded_url: string }>; }; referenced_tweets?: Array<{ type: 'retweeted' | 'quoted' | 'replied_to'; id: string; }>; } export interface TwitterMetrics { impression_count: number; engagement_count: number; retweet_count: number; reply_count: number; like_count: number; quote_count: number; bookmark_count: number; url_link_clicks: number; user_profile_clicks: number; video_view_count?: number; } export interface TwitterSearchParams { query: string; start_time?: string; end_time?: string; max_results?: number; next_token?: string; since_id?: string; until_id?: string; sort_order?: 'recency' | 'relevancy'; }