@memberjunction/actions-bizapps-social
Version:
Social Media Actions for MemberJunction - Twitter, LinkedIn, Facebook, Instagram, TikTok, YouTube, HootSuite, Buffer
672 lines (579 loc) • 22.9 kB
text/typescript
import { RegisterClass } from '@memberjunction/global';
import { BaseSocialMediaAction, SocialPost, SocialAnalytics, MediaFile } from '../../base/base-social.action';
import { LogStatus } from '@memberjunction/core';
import axios from 'axios';
import { BaseAction } from '@memberjunction/actions';
import { ActionParam, ActionResultSimple, RunActionParams } from '@memberjunction/actions-base';
// ---------------------------------------------------------------------------
// Buffer API string unions
// ---------------------------------------------------------------------------
export type BufferPostStatus = 'draft' | 'buffer' | 'sent' | 'failed' | 'canceled' | 'approved' | 'rejected';
export type BufferShareMode = 'addToQueue' | 'shareNext' | 'shareNow' | 'customScheduled';
const MAX_SEARCH_PAGES = 50;
const MAX_RATE_LIMIT_RETRIES = 3;
// ---------------------------------------------------------------------------
// GraphQL response types
// ---------------------------------------------------------------------------
interface GraphQLErrorEntry {
message: string;
locations?: Array<{ line: number; column: number }>;
path?: string[];
extensions?: Record<string, unknown>;
}
interface GraphQLResponse<T> {
data?: T;
errors?: GraphQLErrorEntry[];
}
// ---------------------------------------------------------------------------
// Buffer domain types (GraphQL schema)
// ---------------------------------------------------------------------------
export interface BufferChannel {
id: string;
name: string;
service: string;
displayName: string;
avatar: string;
isDisconnected: boolean;
type: string;
timezone: string;
organizationId: string;
createdAt: string;
updatedAt: string;
isQueuePaused: boolean;
serviceId: string;
}
export interface BufferAssets {
images?: Array<{ url: string; thumbnailUrl?: string }>;
videos?: Array<{ url: string; thumbnailUrl?: string }>;
documents?: Array<{ url: string; title?: string }>;
link?: { url: string; title?: string; description?: string; thumbnailUrl?: string };
}
export interface BufferPost {
id: string;
text: string;
status: BufferPostStatus;
dueAt: string | null;
sentAt: string | null;
createdAt: string;
updatedAt: string;
channelId: string;
channelService: string;
schedulingType: string;
via: string;
assets: BufferAssets | null;
tags: Array<{ id: string; name: string }>;
}
interface BufferPageInfo {
hasNextPage: boolean;
endCursor: string | null;
}
interface BufferPostsConnection {
edges: Array<{ node: BufferPost }>;
pageInfo: BufferPageInfo;
totalCount: number;
}
interface BufferOrganization {
id: string;
name: string;
}
interface BufferAccount {
id: string;
name: string;
organizations: BufferOrganization[];
}
// ---------------------------------------------------------------------------
// Query/mutation data shapes
// ---------------------------------------------------------------------------
interface AccountQueryData {
account: BufferAccount;
}
interface ChannelsQueryData {
channels: BufferChannel[];
}
interface PostsQueryData {
posts: BufferPostsConnection;
}
interface PostMutationResult {
post?: BufferPost;
message?: string;
}
interface CreatePostMutationData {
createPost: PostMutationResult;
}
interface DeletePostMutationData {
deletePost: PostMutationResult;
}
// ---------------------------------------------------------------------------
// Custom error
// ---------------------------------------------------------------------------
export class BufferGraphQLError extends Error {
public readonly Extensions?: Record<string, unknown>;
constructor(message: string, extensions?: Record<string, unknown>) {
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.
*/
export abstract class BufferBaseAction extends BaseSocialMediaAction {
protected get platformName(): string {
return 'Buffer';
}
protected get apiBaseUrl(): string {
return 'https://api.buffer.com';
}
/** Common params shared by all Buffer actions. */
protected get bufferCommonParams(): ActionParam[] {
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.
*/
protected async ensureAuthenticated(params: RunActionParams): Promise<ActionResultSimple | null> {
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. */
protected setOutputParam(params: ActionParam[], name: string, value: unknown): void {
const param = params.find((p) => p.Name === name);
if (param) param.Value = value;
}
/** Build a standardized error result from a caught exception. */
protected buildErrorResult(error: unknown, verb: string, params: ActionParam[]): ActionResultSimple {
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. */
protected groupPostsByDay(posts: SocialPost[], dateField: 'publishedAt' | 'scheduledFor'): Record<string, number> {
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;
},
{} as Record<string, number>,
);
}
// -----------------------------------------------------------------------
// GraphQL execution
// -----------------------------------------------------------------------
/** Execute a GraphQL query or mutation against the Buffer API. */
protected async executeGraphQL<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const token = this.getAccessToken();
if (!token) {
throw new Error('No access token available for Buffer API');
}
try {
const response = await axios.post<GraphQLResponse<T>>(
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<T>(error, query, variables, 0);
}
}
private throwOnGraphQLErrors<T>(body: GraphQLResponse<T>): void {
if (body.errors?.length) {
const first = body.errors[0];
throw new BufferGraphQLError(first.message, first.extensions);
}
}
private async handleExecutionError<T>(error: unknown, query: string, variables: Record<string, unknown> | undefined, retryCount: number): Promise<T> {
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<T>(query, variables);
} catch (retryError) {
return this.handleExecutionError<T>(retryError, query, variables, retryCount + 1);
}
}
throw error;
}
// -----------------------------------------------------------------------
// Organization ID resolution
// -----------------------------------------------------------------------
/** Resolve org ID from params or by fetching the account's first org. */
protected async resolveOrganizationId(params: ActionParam[]): Promise<string> {
const explicit = this.getParamValue(params, 'OrganizationID') as string | null;
if (explicit) return explicit;
const data = await this.executeGraphQL<AccountQueryData>(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. */
protected async fetchChannels(organizationId: string): Promise<BufferChannel[]> {
const data = await this.executeGraphQL<ChannelsQueryData>(CHANNELS_QUERY, {
input: { organizationId },
});
return data.channels;
}
// -----------------------------------------------------------------------
// Post operations
// -----------------------------------------------------------------------
/** Fetch posts with optional filters and cursor-based pagination. */
protected async fetchPosts(
organizationId: string,
filters?: {
channelIds?: string[];
status?: BufferPostStatus;
startDate?: string;
endDate?: string;
tags?: string[];
},
first?: number,
after?: string,
): Promise<BufferPostsConnection> {
const input = this.buildPostsInput(organizationId, filters);
const variables: Record<string, unknown> = { input };
if (first != null) variables.first = first;
if (after) variables.after = after;
const data = await this.executeGraphQL<PostsQueryData>(POSTS_QUERY, variables);
return data.posts;
}
private buildPostsInput(
organizationId: string,
filters?: {
channelIds?: string[];
status?: BufferPostStatus;
startDate?: string;
endDate?: string;
tags?: string[];
},
): Record<string, unknown> {
const input: Record<string, unknown> = { organizationId };
if (!filters) return input;
const filter: Record<string, unknown> = {};
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. */
protected async createBufferPost(input: {
channelId: string;
text: string;
mode?: BufferShareMode;
dueAt?: string;
schedulingType?: string;
assets?: BufferAssets;
tagIds?: string[];
}): Promise<BufferPost> {
const data = await this.executeGraphQL<CreatePostMutationData>(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. */
protected async deleteBufferPost(postId: string): Promise<boolean> {
const data = await this.executeGraphQL<DeletePostMutationData>(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)
// -----------------------------------------------------------------------
protected async refreshAccessToken(): Promise<void> {
LogStatus('Buffer API uses API keys which do not require refresh');
}
// -----------------------------------------------------------------------
// Media upload — not supported as a standalone operation in the GraphQL API
// -----------------------------------------------------------------------
protected async uploadSingleMedia(_file: MediaFile): Promise<string> {
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)
// -----------------------------------------------------------------------
protected async searchPosts(params: {
query?: string;
hashtags?: string[];
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
channelIds?: string[];
organizationId?: string;
}): Promise<SocialPost[]> {
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));
}
private buildSearchFilters(params: { channelIds?: string[]; startDate?: Date; endDate?: Date }): {
channelIds?: string[];
status: BufferPostStatus;
startDate?: string;
endDate?: string;
} {
const filters: { channelIds?: string[]; status: BufferPostStatus; startDate?: string; endDate?: string } = {
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;
}
private async collectSearchResults(
params: { query?: string; hashtags?: string[]; limit?: number; organizationId?: string },
filters: { channelIds?: string[]; status: BufferPostStatus; startDate?: string; endDate?: string },
): Promise<SocialPost[]> {
const limit = params.limit || 100;
const pageSize = Math.min(limit, 100);
const results: SocialPost[] = [];
let cursor: string | undefined;
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. */
private applyClientSideFilters(posts: SocialPost[], query?: string, hashtags?: string[]): SocialPost[] {
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
// -----------------------------------------------------------------------
protected normalizePost(bufferPost: BufferPost): SocialPost {
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,
},
};
}
private extractAssetUrls(assets: BufferAssets | null): string[] {
if (!assets) return [];
const urls: string[] = [];
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)
// -----------------------------------------------------------------------
protected normalizeAnalytics(bufferStats: Record<string, number>): SocialAnalytics {
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
// -----------------------------------------------------------------------
protected extractHashtags(content: string): string[] {
const regex = /#(\w+)/g;
const hashtags: string[] = [];
let match;
while ((match = regex.exec(content)) !== null) {
hashtags.push(match[1].toLowerCase());
}
return hashtags;
}
// -----------------------------------------------------------------------
// Error mapping
// -----------------------------------------------------------------------
protected mapBufferError(error: unknown): string {
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';
}
private mapGraphQLErrorCode(error: BufferGraphQLError): string {
const code = error.Extensions?.['code'] as string | undefined;
if (code === 'UNAUTHORIZED') return 'INVALID_TOKEN';
if (code === 'FORBIDDEN') return 'INSUFFICIENT_PERMISSIONS';
if (code === 'NOT_FOUND') return 'POST_NOT_FOUND';
return 'PLATFORM_ERROR';
}
private mapHttpStatusCode(status: number, data: unknown): string {
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 as Record<string, unknown>)['error'];
if (typeof errorField === 'string' && errorField.includes('media')) {
return 'INVALID_MEDIA';
}
}
return 'PLATFORM_ERROR';
}
}