UNPKG

@memberjunction/actions-bizapps-social

Version:

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

347 lines (308 loc) 11.8 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction, MediaFile, SocialPost, SearchParams } 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 HootSuite actions. * Handles HootSuite-specific authentication, API interactions, and rate limiting. */ @RegisterClass(BaseAction, 'HootSuiteBaseAction') export abstract class HootSuiteBaseAction extends BaseSocialMediaAction { protected get platformName(): string { return 'HootSuite'; } protected get apiBaseUrl(): string { return 'https://platform.hootsuite.com/v1'; } /** * 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(`HootSuite 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 HootSuite'); } try { const response = await axios.post('https://platform.hootsuite.com/oauth2/token', { 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 }, { 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('HootSuite access token refreshed successfully'); } catch (error) { LogError(`Failed to refresh HootSuite access token: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Upload media to HootSuite */ protected async uploadSingleMedia(file: MediaFile): Promise<string> { try { // First, request an upload URL const uploadRequest = await this.axiosInstance.post('/media', { mimeType: file.mimeType, sizeBytes: file.size }); const { uploadUrl, mediaId } = uploadRequest.data; // Upload the file to the provided URL const fileData = typeof file.data === 'string' ? Buffer.from(file.data, 'base64') : file.data; await axios.put(uploadUrl, fileData, { headers: { 'Content-Type': file.mimeType, 'Content-Length': file.size.toString() } }); // Wait for processing await this.waitForMediaProcessing(mediaId); return mediaId; } catch (error) { LogError(`Failed to upload media to HootSuite: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Wait for media to finish processing */ private async waitForMediaProcessing(mediaId: string, maxAttempts: number = 30): Promise<void> { for (let i = 0; i < maxAttempts; i++) { try { const response = await this.axiosInstance.get(`/media/${mediaId}`); const { state } = response.data; if (state === 'READY') { return; } else if (state === 'FAILED') { throw new Error('Media processing failed'); } // Wait 2 seconds before next check await new Promise(resolve => setTimeout(resolve, 2000)); } catch (error) { if (i === maxAttempts - 1) { throw new Error(`Media processing timeout for ${mediaId}`); } } } } /** * Get social profiles for the authenticated user */ protected async getSocialProfiles(): Promise<HootSuiteProfile[]> { try { const response = await this.axiosInstance.get('/socialProfiles'); return response.data.data || []; } catch (error) { LogError(`Failed to get social profiles: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } /** * Make a paginated request to HootSuite API */ protected async makePaginatedRequest<T>( endpoint: string, params: Record<string, any> = {} ): Promise<T[]> { const results: T[] = []; let cursor: string | null = null; const limit = params.limit || 50; do { const queryParams: any = { ...params, limit }; if (cursor) { queryParams.cursor = cursor; } const response = await this.axiosInstance.get(endpoint, { params: queryParams }); const data = response.data; if (data.data && Array.isArray(data.data)) { results.push(...data.data); } cursor = data.cursor || null; // Check if we've reached the desired number of results if (params.maxResults && results.length >= params.maxResults) { return results.slice(0, params.maxResults); } } while (cursor); return results; } /** * Format date for HootSuite API (ISO 8601) */ protected formatHootSuiteDate(date: Date | string): string { if (typeof date === 'string') { date = new Date(date); } return date.toISOString(); } /** * Parse HootSuite date string */ protected parseHootSuiteDate(dateString: string): Date { return new Date(dateString); } /** * Convert HootSuite post to common format */ protected normalizePost(hootsuitePost: HootSuitePost): SocialPost { return { id: hootsuitePost.id, platform: 'HootSuite', profileId: hootsuitePost.socialProfileIds.join(','), // Multiple profiles possible content: hootsuitePost.text, mediaUrls: hootsuitePost.mediaIds || [], publishedAt: this.parseHootSuiteDate(hootsuitePost.createdTime), scheduledFor: hootsuitePost.scheduledTime ? this.parseHootSuiteDate(hootsuitePost.scheduledTime) : undefined, platformSpecificData: { state: hootsuitePost.state, tags: hootsuitePost.tags, location: hootsuitePost.location, socialProfileIds: hootsuitePost.socialProfileIds } }; } /** * 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 HootSuiteSearchPostsAction'); } /** * Handle HootSuite-specific errors */ protected handleHootSuiteError(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'); 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: HootSuite service error'); default: throw new Error(`HootSuite API Error (${status}): ${errorData.message || 'Unknown error'}`); } } else if (error.request) { throw new Error('Network Error: No response from HootSuite'); } else { throw new Error(`Request Error: ${error.message}`); } } } /** * HootSuite-specific interfaces */ export interface HootSuiteProfile { id: string; socialNetworkId: string; socialNetworkUserId: string; avatarUrl: string; displayName: string; type: string; ownerId: string; } export interface HootSuitePost { id: string; socialProfileIds: string[]; text: string; scheduledTime?: string; createdTime: string; state: 'SCHEDULED' | 'PUBLISHED' | 'FAILED' | 'DRAFT'; mediaIds?: string[]; tags?: string[]; location?: { latitude: number; longitude: number; }; } export interface HootSuiteAnalytics { postId: string; metrics: { likes: number; comments: number; shares: number; clicks: number; impressions: number; engagements: number; reach: number; }; period: { start: string; end: string; }; }