UNPKG

@memberjunction/actions-bizapps-social

Version:

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

465 lines 19.3 kB
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; }; import { RegisterClass } from '@memberjunction/global'; import { BaseSocialMediaAction } from '../../base/base-social.action.js'; import { LogStatus } from '@memberjunction/core'; import axios from 'axios'; import { BaseAction } from '@memberjunction/actions'; const MAX_SEARCH_PAGES = 50; const MAX_RATE_LIMIT_RETRIES = 3; // --------------------------------------------------------------------------- // Custom error // --------------------------------------------------------------------------- export class BufferGraphQLError extends Error { constructor(message, extensions) { super(message); this.name = 'BufferGraphQLError'; this.Extensions = extensions; } } // --------------------------------------------------------------------------- // GraphQL documents // --------------------------------------------------------------------------- const ACCOUNT_QUERY = ` query GetAccount { account { id name organizations { id name } } } `; const CHANNELS_QUERY = ` query GetChannels($input: ChannelsInput!) { channels(input: $input) { id name service displayName avatar isDisconnected type timezone organizationId createdAt updatedAt isQueuePaused serviceId } } `; const POSTS_QUERY = ` query GetPosts($input: PostsInput!, $first: Int, $after: String) { posts(input: $input, first: $first, after: $after) { edges { node { id text status dueAt sentAt createdAt updatedAt channelId channelService schedulingType via assets { images { url thumbnailUrl } videos { url thumbnailUrl } documents { url title } link { url title description thumbnailUrl } } tags { id name } } } pageInfo { hasNextPage endCursor } totalCount } } `; const CREATE_POST_MUTATION = ` mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { ... on PostActionSuccess { post { id text status dueAt sentAt createdAt channelId channelService } } ... on MutationError { message } } } `; const DELETE_POST_MUTATION = ` mutation DeletePost($input: DeletePostInput!) { deletePost(input: $input) { ... on PostActionSuccess { post { id } } ... on MutationError { message } } } `; // --------------------------------------------------------------------------- // Base action // --------------------------------------------------------------------------- /** * Base class for all Buffer social media actions. * Uses Buffer's GraphQL API at https://api.buffer.com. * * Migration note: replaces the deprecated v1 REST API at api.bufferapp.com/1. * Key concept renames: profiles → channels, updates → posts. */ let BufferBaseAction = class BufferBaseAction extends BaseSocialMediaAction { get platformName() { return 'Buffer'; } get apiBaseUrl() { return 'https://api.buffer.com'; } /** Common params shared by all Buffer actions. */ get bufferCommonParams() { return [...this.commonSocialParams, { Name: 'OrganizationID', Type: 'Input', Value: null }]; } // ----------------------------------------------------------------------- // Action helpers — reduce boilerplate in subclasses // ----------------------------------------------------------------------- /** * Validate CompanyIntegrationID and initialize OAuth. * * Pass the full `RunActionParams` so the per-request provider on `params.Provider` is * threaded into `initializeOAuth` (multi-tenant correctness — every entity load/save * inside the OAuth flow binds to the request's connection, not the global default). * * Returns null on success, or an error result. */ async ensureAuthenticated(params) { const companyIntegrationId = this.getParamValue(params.Params, 'CompanyIntegrationID'); if (!companyIntegrationId) { return { Success: false, ResultCode: 'MISSING_PARAM', Message: 'CompanyIntegrationID is required', Params: params.Params }; } if (!(await this.initializeOAuth(companyIntegrationId, params))) { return { Success: false, ResultCode: 'INVALID_TOKEN', Message: 'Failed to initialize Buffer connection', Params: params.Params }; } return null; } /** Set an output parameter value by name. */ setOutputParam(params, name, value) { const param = params.find((p) => p.Name === name); if (param) param.Value = value; } /** Build a standardized error result from a caught exception. */ buildErrorResult(error, verb, params) { const message = error instanceof Error ? error.message : 'Unknown error occurred'; const resultCode = this.mapBufferError(error); return { Success: false, ResultCode: resultCode, Message: `Failed to ${verb}: ${message}`, Params: params }; } /** Group posts by day using a date accessor. */ groupPostsByDay(posts, dateField) { return posts.reduce((acc, post) => { const date = dateField === 'scheduledFor' ? post.scheduledFor : post.publishedAt; if (date) { const day = date.toISOString().split('T')[0]; acc[day] = (acc[day] || 0) + 1; } return acc; }, {}); } // ----------------------------------------------------------------------- // GraphQL execution // ----------------------------------------------------------------------- /** Execute a GraphQL query or mutation against the Buffer API. */ async executeGraphQL(query, variables) { const token = this.getAccessToken(); if (!token) { throw new Error('No access token available for Buffer API'); } try { const response = await axios.post(this.apiBaseUrl, { query, variables }, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, timeout: 30000, }); this.throwOnGraphQLErrors(response.data); if (!response.data.data) { throw new Error('No data in Buffer GraphQL response'); } return response.data.data; } catch (error) { return this.handleExecutionError(error, query, variables, 0); } } throwOnGraphQLErrors(body) { if (body.errors?.length) { const first = body.errors[0]; throw new BufferGraphQLError(first.message, first.extensions); } } async handleExecutionError(error, query, variables, retryCount) { if (error instanceof BufferGraphQLError) throw error; if (axios.isAxiosError(error) && error.response?.status === 429) { if (retryCount >= MAX_RATE_LIMIT_RETRIES) { throw new Error(`Buffer API rate limit exceeded after ${MAX_RATE_LIMIT_RETRIES} retries`); } const retryAfter = error.response.headers['retry-after']; const seconds = retryAfter ? parseInt(String(retryAfter)) || 60 : 60; await this.handleRateLimit(seconds); try { return await this.executeGraphQL(query, variables); } catch (retryError) { return this.handleExecutionError(retryError, query, variables, retryCount + 1); } } throw error; } // ----------------------------------------------------------------------- // Organization ID resolution // ----------------------------------------------------------------------- /** Resolve org ID from params or by fetching the account's first org. */ async resolveOrganizationId(params) { const explicit = this.getParamValue(params, 'OrganizationID'); if (explicit) return explicit; const data = await this.executeGraphQL(ACCOUNT_QUERY); const orgs = data.account.organizations; if (orgs.length === 0) { throw new Error('No organizations found for this Buffer account'); } return orgs[0].id; } // ----------------------------------------------------------------------- // Channel operations // ----------------------------------------------------------------------- /** Fetch all channels for an organization. */ async fetchChannels(organizationId) { const data = await this.executeGraphQL(CHANNELS_QUERY, { input: { organizationId }, }); return data.channels; } // ----------------------------------------------------------------------- // Post operations // ----------------------------------------------------------------------- /** Fetch posts with optional filters and cursor-based pagination. */ async fetchPosts(organizationId, filters, first, after) { const input = this.buildPostsInput(organizationId, filters); const variables = { input }; if (first != null) variables.first = first; if (after) variables.after = after; const data = await this.executeGraphQL(POSTS_QUERY, variables); return data.posts; } buildPostsInput(organizationId, filters) { const input = { organizationId }; if (!filters) return input; const filter = {}; if (filters.channelIds?.length) filter.channelIds = filters.channelIds; if (filters.status) filter.status = filters.status; if (filters.startDate) filter.startDate = filters.startDate; if (filters.endDate) filter.endDate = filters.endDate; if (filters.tags?.length) filter.tags = filters.tags; if (Object.keys(filter).length > 0) input.filter = filter; return input; } /** Create a post via the createPost mutation. */ async createBufferPost(input) { const data = await this.executeGraphQL(CREATE_POST_MUTATION, { input }); if (data.createPost.message) { throw new BufferGraphQLError(data.createPost.message); } if (!data.createPost.post) { throw new Error('createPost returned no post data'); } return data.createPost.post; } /** Delete a post via the deletePost mutation. */ async deleteBufferPost(postId) { const data = await this.executeGraphQL(DELETE_POST_MUTATION, { input: { postId } }); if (data.deletePost.message) { throw new BufferGraphQLError(data.deletePost.message); } return !!data.deletePost.post; } // ----------------------------------------------------------------------- // Token refresh (API keys don't expire) // ----------------------------------------------------------------------- async refreshAccessToken() { LogStatus('Buffer API uses API keys which do not require refresh'); } // ----------------------------------------------------------------------- // Media upload — not supported as a standalone operation in the GraphQL API // ----------------------------------------------------------------------- async uploadSingleMedia(_file) { throw new Error('Buffer GraphQL API does not support standalone media upload. ' + 'Pass pre-hosted media URLs via the assets parameter on createPost instead.'); } // ----------------------------------------------------------------------- // Post search (implements abstract from BaseSocialMediaAction) // ----------------------------------------------------------------------- async searchPosts(params) { if (!params.organizationId) { throw new Error('OrganizationID is required for searching Buffer posts'); } const filters = this.buildSearchFilters(params); const collected = await this.collectSearchResults(params, filters); const offset = params.offset || 0; return collected.slice(offset, offset + (params.limit || 100)); } buildSearchFilters(params) { const filters = { status: 'sent', }; if (params.channelIds?.length) filters.channelIds = params.channelIds; if (params.startDate) filters.startDate = params.startDate.toISOString(); if (params.endDate) filters.endDate = params.endDate.toISOString(); return filters; } async collectSearchResults(params, filters) { const limit = params.limit || 100; const pageSize = Math.min(limit, 100); const results = []; let cursor; let hasMore = true; let pagesRead = 0; while (hasMore && results.length < limit && pagesRead < MAX_SEARCH_PAGES) { const connection = await this.fetchPosts(params.organizationId, filters, pageSize, cursor); const posts = connection.edges.map((edge) => this.normalizePost(edge.node)); const filtered = this.applyClientSideFilters(posts, params.query, params.hashtags); results.push(...filtered); hasMore = connection.pageInfo.hasNextPage; cursor = connection.pageInfo.endCursor || undefined; pagesRead++; } return results; } /** Apply text/hashtag filters the GraphQL API doesn't support natively. */ applyClientSideFilters(posts, query, hashtags) { return posts.filter((post) => { if (query && !post.content.toLowerCase().includes(query.toLowerCase())) { return false; } if (hashtags?.length) { const postTags = this.extractHashtags(post.content); const hasMatch = hashtags.some((tag) => postTags.includes(tag.toLowerCase().replace('#', ''))); if (!hasMatch) return false; } return true; }); } // ----------------------------------------------------------------------- // Post normalization // ----------------------------------------------------------------------- normalizePost(bufferPost) { return { id: bufferPost.id, platform: 'Buffer', profileId: bufferPost.channelId, content: bufferPost.text || '', mediaUrls: this.extractAssetUrls(bufferPost.assets), publishedAt: bufferPost.sentAt ? new Date(bufferPost.sentAt) : new Date(bufferPost.createdAt), scheduledFor: bufferPost.dueAt ? new Date(bufferPost.dueAt) : undefined, platformSpecificData: { channelService: bufferPost.channelService, status: bufferPost.status, via: bufferPost.via, schedulingType: bufferPost.schedulingType, tags: bufferPost.tags, }, }; } extractAssetUrls(assets) { if (!assets) return []; const urls = []; if (assets.images) urls.push(...assets.images.map((img) => img.url)); if (assets.videos) urls.push(...assets.videos.map((vid) => vid.url)); if (assets.documents) urls.push(...assets.documents.map((doc) => doc.url)); if (assets.link) urls.push(assets.link.url); return urls; } // ----------------------------------------------------------------------- // Analytics normalization (retained for interface compatibility; // the GraphQL API does not yet expose analytics) // ----------------------------------------------------------------------- normalizeAnalytics(bufferStats) { return { impressions: bufferStats['reach'] || 0, engagements: (bufferStats['clicks'] || 0) + (bufferStats['favorites'] || 0) + (bufferStats['mentions'] || 0) + (bufferStats['retweets'] || 0) + (bufferStats['shares'] || 0) + (bufferStats['comments'] || 0), clicks: bufferStats['clicks'] || 0, shares: bufferStats['shares'] || bufferStats['retweets'] || 0, comments: bufferStats['comments'] || bufferStats['mentions'] || 0, likes: bufferStats['favorites'] || bufferStats['likes'] || 0, reach: bufferStats['reach'] || 0, saves: undefined, videoViews: undefined, platformMetrics: bufferStats, }; } // ----------------------------------------------------------------------- // Hashtag extraction // ----------------------------------------------------------------------- extractHashtags(content) { const regex = /#(\w+)/g; const hashtags = []; let match; while ((match = regex.exec(content)) !== null) { hashtags.push(match[1].toLowerCase()); } return hashtags; } // ----------------------------------------------------------------------- // Error mapping // ----------------------------------------------------------------------- mapBufferError(error) { if (error instanceof BufferGraphQLError) { return this.mapGraphQLErrorCode(error); } if (axios.isAxiosError(error) && error.response) { return this.mapHttpStatusCode(error.response.status, error.response.data); } return 'PLATFORM_ERROR'; } mapGraphQLErrorCode(error) { const code = error.Extensions?.['code']; if (code === 'UNAUTHORIZED') return 'INVALID_TOKEN'; if (code === 'FORBIDDEN') return 'INSUFFICIENT_PERMISSIONS'; if (code === 'NOT_FOUND') return 'POST_NOT_FOUND'; return 'PLATFORM_ERROR'; } mapHttpStatusCode(status, data) { if (status === 401) return 'INVALID_TOKEN'; if (status === 429) return 'RATE_LIMIT'; if (status === 403) return 'INSUFFICIENT_PERMISSIONS'; if (status === 404) return 'POST_NOT_FOUND'; if (data && typeof data === 'object') { const errorField = data['error']; if (typeof errorField === 'string' && errorField.includes('media')) { return 'INVALID_MEDIA'; } } return 'PLATFORM_ERROR'; } }; BufferBaseAction = __decorate([ RegisterClass(BaseAction, 'BufferBaseAction') ], BufferBaseAction); export { BufferBaseAction }; //# sourceMappingURL=buffer-base.action.js.map