@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
482 lines (437 loc) • 17.2 kB
text/typescript
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 { BaseAction } from '@memberjunction/actions';
/**
* Base class for all LinkedIn actions.
* Handles LinkedIn-specific authentication, API interactions, and rate limiting.
* Uses LinkedIn Marketing Developer Platform API v2.
*/
(BaseAction, 'LinkedInBaseAction')
export abstract class LinkedInBaseAction extends BaseSocialMediaAction {
protected get platformName(): string {
return 'LinkedIn';
}
protected get apiBaseUrl(): string {
return 'https://api.linkedin.com/v2';
}
/**
* 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',
'X-Restli-Protocol-Version': '2.0.0' // LinkedIn specific header
}
});
// 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(`LinkedIn Rate Limit - Remaining: ${rateLimitInfo.remaining}/${rateLimitInfo.limit}, Reset: ${rateLimitInfo.reset}`);
}
return response;
},
async (error: AxiosError) => {
if (error.response?.status === 429) {
// Rate limit exceeded
const retryAfter = error.response.headers['retry-after'];
const waitTime = retryAfter ? parseInt(retryAfter) : 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 LinkedIn');
}
try {
const response = await axios.post('https://www.linkedin.com/oauth/v2/accessToken',
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.getCustomAttribute(2) || '', // Client ID stored in CustomAttribute2
client_secret: this.getCustomAttribute(3) || '' // Client Secret stored in CustomAttribute3
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, refresh_token: newRefreshToken, expires_in } = response.data;
// Update stored tokens
await this.updateStoredTokens(
access_token,
newRefreshToken || refreshToken,
expires_in
);
LogStatus('LinkedIn access token refreshed successfully');
} catch (error) {
LogError(`Failed to refresh LinkedIn access token: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get the authenticated user's profile URN
*/
protected async getCurrentUserUrn(): Promise<string> {
try {
const response = await this.axiosInstance.get('/me');
return `urn:li:person:${response.data.id}`;
} catch (error) {
LogError(`Failed to get current user URN: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Get organizations the user has admin access to
*/
protected async getAdminOrganizations(): Promise<LinkedInOrganization[]> {
try {
const response = await this.axiosInstance.get('/organizationalEntityAcls', {
params: {
q: 'roleAssignee',
role: 'ADMINISTRATOR',
projection: '(elements*(*,organizationalTarget~(localizedName)))'
}
});
const organizations: LinkedInOrganization[] = [];
if (response.data.elements) {
for (const element of response.data.elements) {
if (element.organizationalTarget) {
organizations.push({
urn: element.organizationalTarget,
name: element['organizationalTarget~']?.localizedName || 'Unknown',
id: element.organizationalTarget.split(':').pop() || ''
});
}
}
}
return organizations;
} catch (error) {
LogError(`Failed to get admin organizations: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Upload media to LinkedIn
*/
protected async uploadSingleMedia(file: MediaFile): Promise<string> {
try {
// Step 1: Register upload
const registerResponse = await this.axiosInstance.post('/assets?action=registerUpload', {
registerUploadRequest: {
recipes: ['urn:li:digitalmediaRecipe:feedshare-image'],
owner: await this.getCurrentUserUrn(),
serviceRelationships: [{
relationshipType: 'OWNER',
identifier: 'urn:li:userGeneratedContent'
}]
}
});
const uploadUrl = registerResponse.data.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl;
const asset = registerResponse.data.value.asset;
// Step 2: Upload the file
const fileData = typeof file.data === 'string'
? Buffer.from(file.data, 'base64')
: file.data;
await axios.put(uploadUrl, fileData, {
headers: {
'Authorization': `Bearer ${this.getAccessToken()}`,
'Content-Type': file.mimeType
}
});
// Return the asset URN
return asset;
} catch (error) {
LogError(`Failed to upload media to LinkedIn: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Validate media file meets LinkedIn requirements
*/
protected validateMediaFile(file: MediaFile): void {
const supportedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
];
if (!supportedTypes.includes(file.mimeType)) {
throw new Error(`Unsupported media type: ${file.mimeType}. Supported types: ${supportedTypes.join(', ')}`);
}
// LinkedIn image size limits
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new Error(`File size exceeds limit. Max: ${maxSize / 1024 / 1024}MB, Got: ${file.size / 1024 / 1024}MB`);
}
}
/**
* Create a share (post) on LinkedIn
*/
protected async createShare(shareData: LinkedInShareData): Promise<string> {
try {
const response = await this.axiosInstance.post('/ugcPosts', shareData);
return response.data.id;
} catch (error) {
this.handleLinkedInError(error as AxiosError);
}
}
/**
* Get shares for a specific author (person or organization)
*/
protected async getShares(authorUrn: string, count: number = 50, start: number = 0): Promise<LinkedInShare[]> {
try {
const response = await this.axiosInstance.get('/ugcPosts', {
params: {
q: 'authors',
authors: `List(${authorUrn})`,
count: count,
start: start
}
});
return response.data.elements || [];
} catch (error) {
LogError(`Failed to get shares: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
/**
* Convert LinkedIn share to common format
*/
protected normalizePost(linkedInShare: LinkedInShare): SocialPost {
const publishedAt = new Date(linkedInShare.firstPublishedAt || linkedInShare.created.time);
// Extract media URLs
const mediaUrls: string[] = [];
if (linkedInShare.specificContent?.['com.linkedin.ugc.ShareContent']?.media) {
for (const media of linkedInShare.specificContent['com.linkedin.ugc.ShareContent'].media) {
if (media.media) {
mediaUrls.push(media.media);
}
}
}
return {
id: linkedInShare.id,
platform: 'LinkedIn',
profileId: linkedInShare.author,
content: linkedInShare.specificContent?.['com.linkedin.ugc.ShareContent']?.shareCommentary?.text || '',
mediaUrls: mediaUrls,
publishedAt: publishedAt,
platformSpecificData: {
lifecycleState: linkedInShare.lifecycleState,
visibility: linkedInShare.visibility,
distribution: linkedInShare.distribution
}
};
}
/**
* Normalize LinkedIn analytics to common format
*/
protected normalizeAnalytics(linkedInAnalytics: LinkedInAnalytics): SocialAnalytics {
return {
impressions: linkedInAnalytics.totalShareStatistics?.impressionCount || 0,
engagements: linkedInAnalytics.totalShareStatistics?.engagement || 0,
clicks: linkedInAnalytics.totalShareStatistics?.clickCount || 0,
shares: linkedInAnalytics.totalShareStatistics?.shareCount || 0,
comments: linkedInAnalytics.totalShareStatistics?.commentCount || 0,
likes: linkedInAnalytics.totalShareStatistics?.likeCount || 0,
reach: linkedInAnalytics.totalShareStatistics?.uniqueImpressionsCount || 0,
platformMetrics: linkedInAnalytics
};
}
/**
* Search for posts - implemented in search action
*/
protected async searchPosts(params: SearchParams): Promise<SocialPost[]> {
// This is implemented in the search-posts.action.ts
throw new Error('Search posts is implemented in LinkedInSearchPostsAction');
}
/**
* Handle LinkedIn-specific errors
*/
protected handleLinkedInError(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.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 LinkedIn scopes.');
case 404:
throw new Error('Not Found: Resource does not exist');
case 422:
throw new Error(`Unprocessable Entity: ${errorData.message || 'Invalid data provided'}`);
case 429:
throw new Error('Rate Limit Exceeded: Too many requests');
case 500:
throw new Error('Internal Server Error: LinkedIn service error');
default:
throw new Error(`LinkedIn API Error (${status}): ${errorData.message || 'Unknown error'}`);
}
} else if (error.request) {
throw new Error('Network Error: No response from LinkedIn');
} else {
throw new Error(`Request Error: ${error.message}`);
}
}
/**
* Parse LinkedIn-specific rate limit headers
*/
protected parseRateLimitHeaders(headers: any): { remaining: number; reset: Date; limit: number; } | null {
// LinkedIn uses different header names
const appRemaining = headers['x-app-rate-limit-remaining'];
const appLimit = headers['x-app-rate-limit-limit'];
const memberRemaining = headers['x-member-rate-limit-remaining'];
const memberLimit = headers['x-member-rate-limit-limit'];
// Use the more restrictive limit
const remaining = Math.min(
appRemaining ? parseInt(appRemaining) : Infinity,
memberRemaining ? parseInt(memberRemaining) : Infinity
);
const limit = Math.min(
appLimit ? parseInt(appLimit) : Infinity,
memberLimit ? parseInt(memberLimit) : Infinity
);
if (remaining !== Infinity && limit !== Infinity) {
// LinkedIn resets rate limits at the top of each hour
const now = new Date();
const reset = new Date(now);
reset.setHours(reset.getHours() + 1, 0, 0, 0);
return { remaining, reset, limit };
}
return null;
}
}
/**
* LinkedIn-specific interfaces
*/
export interface LinkedInOrganization {
urn: string;
name: string;
id: string;
}
export interface LinkedInShareData {
author: string; // URN of the author (person or organization)
lifecycleState: 'PUBLISHED' | 'DRAFT';
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: {
text: string;
};
shareMediaCategory: 'NONE' | 'ARTICLE' | 'IMAGE' | 'VIDEO' | 'RICH';
media?: Array<{
status: 'READY';
media: string; // Asset URN
title?: {
text: string;
};
description?: {
text: string;
};
}>;
};
};
visibility: {
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' | 'CONNECTIONS' | 'LOGGED_IN' | 'CONTAINER';
};
distribution?: {
linkedInDistributionTarget?: {
visibleToGuest?: boolean;
};
};
}
export interface LinkedInShare {
id: string;
author: string;
created: {
actor: string;
time: number;
};
firstPublishedAt?: number;
lastModified?: {
actor: string;
time: number;
};
lifecycleState: string;
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: {
text: string;
};
shareMediaCategory: string;
media?: Array<{
media: string;
title?: {
text: string;
};
}>;
};
};
visibility: {
'com.linkedin.ugc.MemberNetworkVisibility': string;
};
distribution?: any;
}
export interface LinkedInAnalytics {
totalShareStatistics?: {
impressionCount: number;
clickCount: number;
engagement: number;
likeCount: number;
commentCount: number;
shareCount: number;
uniqueImpressionsCount: number;
};
timeRange?: {
start: number;
end: number;
};
}
export interface LinkedInArticle {
author: string;
publishedAt: number;
coverImage?: string;
title: string;
description?: string;
content: string;
visibility: string;
}