UNPKG

@memberjunction/actions-bizapps-social

Version:

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

357 lines (313 loc) 12.4 kB
import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction, SocialPost, SocialAnalytics, MediaFile } from '../../base/base-social.action'; import { LogError, LogStatus } from '@memberjunction/core'; import axios, { AxiosInstance, AxiosError } from 'axios'; import { BaseAction } from '@memberjunction/actions'; /** * TikTok video information */ export interface TikTokVideo { id: string; share_url: string; title: string; description: string; duration: number; cover_image_url: string; share_count: number; view_count: number; like_count: number; comment_count: number; create_time: number; } /** * TikTok user information */ export interface TikTokUser { open_id: string; union_id: string; avatar_url: string; display_name: string; bio_description: string; profile_deep_link: string; is_verified: boolean; follower_count: number; following_count: number; likes_count: number; } /** * Base class for all TikTok social media actions. * Handles TikTok-specific authentication and API interaction patterns. */ @RegisterClass(BaseAction, 'TikTokBaseAction') export abstract class TikTokBaseAction extends BaseSocialMediaAction { protected get platformName(): string { return 'TikTok'; } protected get apiBaseUrl(): string { return 'https://open-api.tiktok.com'; } private axiosInstance: AxiosInstance | null = null; /** * Initialize axios instance with interceptors */ protected getAxiosInstance(): AxiosInstance { if (!this.axiosInstance) { this.axiosInstance = axios.create({ baseURL: this.apiBaseUrl, timeout: 30000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Request interceptor for logging this.axiosInstance.interceptors.request.use( (config) => { this.logApiRequest(config.method?.toUpperCase() || 'GET', config.url || '', config.data); return config; }, (error) => { LogError(`TikTok API Request Error: ${error.message}`); return Promise.reject(error); } ); // Response interceptor for logging and error handling this.axiosInstance.interceptors.response.use( (response) => { this.logApiResponse(response.data); return response; }, async (error: AxiosError) => { if (error.response?.status === 429) { // Rate limit hit const retryAfter = error.response.headers['retry-after']; await this.handleRateLimit(retryAfter ? parseInt(retryAfter) : undefined); // Retry the request return this.axiosInstance?.request(error.config!); } LogError(`TikTok API Response Error: ${error.message}`); return Promise.reject(error); } ); } return this.axiosInstance; } /** * Make authenticated TikTok API request */ protected async makeTikTokRequest<T>( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any, params?: any ): Promise<T> { const token = this.getAccessToken(); if (!token) { throw new Error('No access token available for TikTok'); } const axios = this.getAxiosInstance(); try { const response = await axios.request<T>({ method, url: endpoint, data, params, headers: { 'Authorization': `Bearer ${token}` } }); return response.data; } catch (error) { if (error instanceof AxiosError) { if (error.response?.status === 401) { // Token might be expired, try to refresh await this.refreshAccessToken(); // Retry with new token const newToken = this.getAccessToken(); const retryResponse = await axios.request<T>({ method, url: endpoint, data, params, headers: { 'Authorization': `Bearer ${newToken}` } }); return retryResponse.data; } const errorMessage = error.response?.data?.error?.message || error.message; throw new Error(`TikTok API error: ${errorMessage}`); } throw error; } } /** * Refresh TikTok access token */ protected async refreshAccessToken(): Promise<void> { const refreshToken = this.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available for TikTok'); } try { const response = await axios.post(`${this.apiBaseUrl}/oauth/refresh_token/`, { client_key: this.getCustomAttribute(2), // Store client key in CustomAttribute2 grant_type: 'refresh_token', refresh_token: refreshToken }); const { access_token, refresh_token: newRefreshToken, expires_in } = response.data.data; // Update stored tokens await this.updateStoredTokens(access_token, newRefreshToken, expires_in); LogStatus('TikTok access token refreshed successfully'); } catch (error) { LogError(`Failed to refresh TikTok access token: ${error}`); throw new Error('Failed to refresh TikTok access token'); } } /** * Upload media to TikTok (requires special approval) */ protected async uploadSingleMedia(file: MediaFile): Promise<string> { // TikTok video upload requires special approval // This is a placeholder implementation throw new Error('TikTok video upload requires special API approval. Please use TikTok Creator Studio for video uploads.'); } /** * Search posts (videos) - TikTok only allows searching user's own videos */ protected async searchPosts(params: any): Promise<SocialPost[]> { // TikTok doesn't provide a general search API for security/privacy reasons // We can only search within a user's own videos const videos = await this.getUserVideos(); let filtered = videos; // Apply filters if provided if (params.query) { const query = params.query.toLowerCase(); filtered = filtered.filter(video => video.title.toLowerCase().includes(query) || video.description.toLowerCase().includes(query) ); } if (params.hashtags && params.hashtags.length > 0) { filtered = filtered.filter(video => { const videoHashtags = this.extractHashtags(video.description); return params.hashtags.some((tag: string) => videoHashtags.includes(tag.toLowerCase()) ); }); } if (params.startDate) { const startTime = new Date(params.startDate).getTime() / 1000; filtered = filtered.filter(video => video.create_time >= startTime); } if (params.endDate) { const endTime = new Date(params.endDate).getTime() / 1000; filtered = filtered.filter(video => video.create_time <= endTime); } // Apply limit and offset if (params.offset) { filtered = filtered.slice(params.offset); } if (params.limit) { filtered = filtered.slice(0, params.limit); } return filtered.map(video => this.normalizePost(video)); } /** * Get user's videos */ protected async getUserVideos(): Promise<TikTokVideo[]> { const response = await this.makeTikTokRequest<any>( '/v2/video/list/', 'GET', undefined, { fields: 'id,share_url,title,description,duration,cover_image_url,share_count,view_count,like_count,comment_count,create_time' } ); return response.data?.videos || []; } /** * Convert TikTok video to common post format */ protected normalizePost(video: TikTokVideo): SocialPost { return { id: video.id, platform: this.platformName, profileId: this.getCustomAttribute(1) || '', // Store user ID in CustomAttribute1 content: video.description || video.title, mediaUrls: [video.cover_image_url], publishedAt: new Date(video.create_time * 1000), analytics: { impressions: video.view_count, engagements: video.like_count + video.comment_count + video.share_count, clicks: 0, // Not available in TikTok API shares: video.share_count, comments: video.comment_count, likes: video.like_count, reach: video.view_count, videoViews: video.view_count, platformMetrics: { duration: video.duration, shareUrl: video.share_url } }, platformSpecificData: { ...video, videoUrl: video.share_url } }; } /** * Normalize TikTok analytics to common format */ protected normalizeAnalytics(platformData: any): SocialAnalytics { return { impressions: platformData.view_count || 0, engagements: (platformData.like_count || 0) + (platformData.comment_count || 0) + (platformData.share_count || 0), clicks: 0, // Not available in TikTok API shares: platformData.share_count || 0, comments: platformData.comment_count || 0, likes: platformData.like_count || 0, reach: platformData.view_count || 0, videoViews: platformData.view_count || 0, platformMetrics: platformData }; } /** * Extract hashtags from video description */ protected extractHashtags(description: string): string[] { const hashtagRegex = /#(\w+)/g; const matches = description.match(hashtagRegex) || []; return matches.map(tag => tag.substring(1).toLowerCase()); } /** * Get current user info */ protected async getCurrentUser(): Promise<TikTokUser> { const response = await this.makeTikTokRequest<any>( '/v2/user/info/', 'GET', undefined, { fields: 'open_id,union_id,avatar_url,display_name,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count' } ); return response.data?.user; } /** * Validate TikTok-specific media requirements */ protected validateMediaFile(file: MediaFile): void { const allowedTypes = ['video/mp4', 'video/quicktime', 'video/webm']; if (!allowedTypes.includes(file.mimeType)) { throw new Error(`TikTok only supports video files. Got: ${file.mimeType}`); } // Max file size: 287.6 MB const maxSize = 287.6 * 1024 * 1024; if (file.size > maxSize) { throw new Error(`File size exceeds TikTok limit of 287.6 MB. Got: ${(file.size / 1024 / 1024).toFixed(2)} MB`); } } }