UNPKG

@memberjunction/actions-bizapps-social

Version:

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

485 lines 19.5 kB
"use strict"; 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.TwitterBaseAction = 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 Twitter/X actions. * Handles Twitter-specific authentication, API interactions, and rate limiting. * Uses Twitter API v2 with OAuth 2.0. */ let TwitterBaseAction = class TwitterBaseAction extends base_social_action_1.BaseSocialMediaAction { get platformName() { return 'Twitter'; } get apiBaseUrl() { return 'https://api.twitter.com/2'; } /** * Upload endpoint for media */ get uploadApiUrl() { return 'https://upload.twitter.com/1.1'; } /** * 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) { 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) { (0, core_1.LogStatus)(`Twitter Rate Limit - Remaining: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}, Reset: ${rateLimitInfo.reset}`); } return response; }, async (error) => { 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 */ async refreshAccessToken() { 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_1.default.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); (0, core_1.LogStatus)('Twitter access token refreshed successfully'); } catch (error) { (0, core_1.LogError)(`Failed to refresh Twitter access token: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get the authenticated user's info */ async getCurrentUser() { 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) { (0, core_1.LogError)(`Failed to get current user: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Upload media to Twitter */ async uploadSingleMedia(file) { try { const fileData = typeof file.data === 'string' ? Buffer.from(file.data, 'base64') : file.data; // Step 1: Initialize upload const initResponse = await axios_1.default.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 form_data_1.default(); 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_1.default.post(`${this.uploadApiUrl}/media/upload.json`, formData, { headers: { 'Authorization': `Bearer ${this.getAccessToken()}`, ...formData.getHeaders() } }); segmentIndex++; } // Step 3: Finalize upload await axios_1.default.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) { (0, core_1.LogError)(`Failed to upload media to Twitter: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Wait for media processing to complete (for videos) */ async waitForMediaProcessing(mediaId, maxWaitTime = 60000) { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { const response = await axios_1.default.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 */ getMediaCategory(mimeType) { 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 */ validateMediaFile(file) { 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; 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 */ async createTweet(tweetData) { try { const response = await this.axiosInstance.post('/tweets', tweetData); return response.data.data; } catch (error) { this.handleTwitterError(error); } } /** * Delete a tweet */ async deleteTweet(tweetId) { try { await this.axiosInstance.delete(`/tweets/${tweetId}`); } catch (error) { this.handleTwitterError(error); } } /** * Get tweets with specified parameters */ async getTweets(endpoint, params = {}) { 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) { (0, core_1.LogError)(`Failed to get tweets: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Get paginated tweets */ async getPaginatedTweets(endpoint, params = {}, maxResults) { const tweets = []; let paginationToken; 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 */ normalizePost(tweet) { 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 */ normalizeAnalytics(twitterMetrics) { 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 */ async searchPosts(params) { // This is implemented in the search-tweets.action.ts throw new Error('Search posts is implemented in TwitterSearchTweetsAction'); } /** * Handle Twitter-specific errors */ handleTwitterError(error) { if (error.response) { const { status, data } = error.response; const errorData = data; 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 */ parseRateLimitHeaders(headers) { 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 */ buildSearchQuery(params) { const parts = []; 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) */ formatTwitterDate(date) { if (typeof date === 'string') { date = new Date(date); } return date.toISOString(); } }; exports.TwitterBaseAction = TwitterBaseAction; exports.TwitterBaseAction = TwitterBaseAction = __decorate([ (0, global_1.RegisterClass)(actions_1.BaseAction, 'TwitterBaseAction') ], TwitterBaseAction); //# sourceMappingURL=twitter-base.action.js.map