@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
485 lines • 19.5 kB
JavaScript
;
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