From 883f898b7df1e151b16a14875dc3b57c5d9b744f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:06:41 -0700 Subject: [PATCH 1/6] feat(buffer): add Buffer integration with posts, channels, and ideas --- apps/docs/components/icons.tsx | 18 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/buffer.mdx | 308 ++++++++++++ .../content/docs/en/integrations/meta.json | 1 + .../app/api/tools/buffer/create-post/route.ts | 69 +++ .../app/api/tools/buffer/edit-post/route.ts | 69 +++ apps/sim/app/api/tools/buffer/server-utils.ts | 217 +++++++++ apps/sim/blocks/blocks/buffer.ts | 449 ++++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 18 + apps/sim/lib/api/contracts/tools/buffer.ts | 121 +++++ apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 51 ++ apps/sim/tools/buffer/create_idea.ts | 136 ++++++ apps/sim/tools/buffer/create_post.ts | 110 +++++ apps/sim/tools/buffer/delete_post.ts | 76 +++ apps/sim/tools/buffer/edit_post.ts | 110 +++++ apps/sim/tools/buffer/get_account.ts | 80 ++++ apps/sim/tools/buffer/get_channels.ts | 81 ++++ apps/sim/tools/buffer/get_post.ts | 74 +++ apps/sim/tools/buffer/get_posts.ts | 176 +++++++ apps/sim/tools/buffer/index.ts | 9 + apps/sim/tools/buffer/types.ts | 443 +++++++++++++++++ apps/sim/tools/registry.ts | 18 + scripts/check-api-validation-contracts.ts | 4 +- 25 files changed, 2643 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/buffer.mdx create mode 100644 apps/sim/app/api/tools/buffer/create-post/route.ts create mode 100644 apps/sim/app/api/tools/buffer/edit-post/route.ts create mode 100644 apps/sim/app/api/tools/buffer/server-utils.ts create mode 100644 apps/sim/blocks/blocks/buffer.ts create mode 100644 apps/sim/lib/api/contracts/tools/buffer.ts create mode 100644 apps/sim/tools/buffer/create_idea.ts create mode 100644 apps/sim/tools/buffer/create_post.ts create mode 100644 apps/sim/tools/buffer/delete_post.ts create mode 100644 apps/sim/tools/buffer/edit_post.ts create mode 100644 apps/sim/tools/buffer/get_account.ts create mode 100644 apps/sim/tools/buffer/get_channels.ts create mode 100644 apps/sim/tools/buffer/get_post.ts create mode 100644 apps/sim/tools/buffer/get_posts.ts create mode 100644 apps/sim/tools/buffer/index.ts create mode 100644 apps/sim/tools/buffer/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 4c58dddc322..8ea07de316d 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2533,6 +2533,24 @@ l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111 ) } +export function BufferIcon(props: SVGProps) { + return ( + + + + ) +} + export function Mem0Icon(props: SVGProps) { return ( = { brex: BrexIcon, brightdata: BrightDataIcon, browser_use: BrowserUseIcon, + buffer: BufferIcon, calcom: CalComIcon, calendly: CalendlyIcon, circleback: CirclebackIcon, diff --git a/apps/docs/content/docs/en/integrations/buffer.mdx b/apps/docs/content/docs/en/integrations/buffer.mdx new file mode 100644 index 00000000000..4ae15ab1e5f --- /dev/null +++ b/apps/docs/content/docs/en/integrations/buffer.mdx @@ -0,0 +1,308 @@ +--- +title: Buffer +description: Schedule and publish social media posts across connected channels +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Buffer into your workflow. Create, schedule, edit, and delete posts across connected social channels (Instagram, LinkedIn, X, Facebook, TikTok, and more), attach images or videos, browse channels, and capture content ideas using the Buffer API. + + + +## Actions + +### `buffer_create_post` + +Create a post in Buffer for a channel — add it to the queue, share it immediately, schedule it for a specific time, or save it as a draft, optionally with an image or video attachment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `channelId` | string | Yes | Channel to create the post for \(find it with the Get Channels operation\) | +| `text` | string | No | Text content of the post \(required unless media is attached\) | +| `mode` | string | Yes | How to share the post: addToQueue, shareNext, shareNow, or customScheduled \(requires dueAt\) | +| `schedulingType` | string | Yes | How the post publishes: automatic \(Buffer publishes it\) or notification \(you get a mobile reminder\) | +| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) | +| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it | +| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL | +| `mediaAltText` | string | No | Alt text for an attached image | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `post` | object | The created post | +| ↳ `id` | string | Post ID | +| ↳ `text` | string | Post text content | +| ↳ `status` | string | Post status \(draft, needs_approval, scheduled, sending, sent, error\) | +| ↳ `via` | string | How the post was created \(buffer, network, api\) | +| ↳ `channelId` | string | Channel the post belongs to | +| ↳ `channelService` | string | Social network of the channel | +| ↳ `schedulingType` | string | How the post publishes \(automatic or notification\) | +| ↳ `shareMode` | string | Share mode used for the post | +| ↳ `isCustomScheduled` | boolean | Whether the post has a custom schedule | +| ↳ `sharedNow` | boolean | Whether the post was shared immediately | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last update timestamp \(ISO 8601\) | +| ↳ `dueAt` | string | Scheduled publish time \(ISO 8601\) | +| ↳ `sentAt` | string | Publish timestamp \(ISO 8601\) | +| ↳ `externalLink` | string | Link to the published post on the social network | +| ↳ `error` | object | Publishing error details when the post failed | +| ↳ `message` | string | Error message | +| ↳ `supportUrl` | string | Support article URL | +| ↳ `rawError` | string | Raw error from the network | +| ↳ `assets` | array | Media attached to the post | +| ↳ `id` | string | Asset ID | +| ↳ `type` | string | Asset type | +| ↳ `mimeType` | string | MIME type of the asset | +| ↳ `source` | string | Source URL of the asset | +| ↳ `thumbnail` | string | Thumbnail URL of the asset | + +### `buffer_edit_post` + +Edit an existing Buffer post — update its text, schedule, or media. Attaching new media replaces the existing attachments + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `postId` | string | Yes | ID of the post to edit | +| `text` | string | No | New text content of the post | +| `mode` | string | Yes | How to share the post: addToQueue, shareNext, shareNow, or customScheduled \(requires dueAt\) | +| `schedulingType` | string | Yes | How the post publishes: automatic \(Buffer publishes it\) or notification \(you get a mobile reminder\) | +| `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) | +| `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it | +| `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments | +| `mediaAltText` | string | No | Alt text for an attached image | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `post` | object | The updated post | +| ↳ `id` | string | Post ID | +| ↳ `text` | string | Post text content | +| ↳ `status` | string | Post status \(draft, needs_approval, scheduled, sending, sent, error\) | +| ↳ `via` | string | How the post was created \(buffer, network, api\) | +| ↳ `channelId` | string | Channel the post belongs to | +| ↳ `channelService` | string | Social network of the channel | +| ↳ `schedulingType` | string | How the post publishes \(automatic or notification\) | +| ↳ `shareMode` | string | Share mode used for the post | +| ↳ `isCustomScheduled` | boolean | Whether the post has a custom schedule | +| ↳ `sharedNow` | boolean | Whether the post was shared immediately | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last update timestamp \(ISO 8601\) | +| ↳ `dueAt` | string | Scheduled publish time \(ISO 8601\) | +| ↳ `sentAt` | string | Publish timestamp \(ISO 8601\) | +| ↳ `externalLink` | string | Link to the published post on the social network | +| ↳ `error` | object | Publishing error details when the post failed | +| ↳ `message` | string | Error message | +| ↳ `supportUrl` | string | Support article URL | +| ↳ `rawError` | string | Raw error from the network | +| ↳ `assets` | array | Media attached to the post | +| ↳ `id` | string | Asset ID | +| ↳ `type` | string | Asset type | +| ↳ `mimeType` | string | MIME type of the asset | +| ↳ `source` | string | Source URL of the asset | +| ↳ `thumbnail` | string | Thumbnail URL of the asset | + +### `buffer_get_posts` + +List posts in a Buffer organization, optionally filtered by channel and status (draft, needs_approval, scheduled, sending, sent, error) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `organizationId` | string | Yes | Buffer organization ID \(find it with the Get Account operation\) | +| `channelIds` | string | No | Comma-separated channel IDs to filter by | +| `status` | string | No | Comma-separated statuses to filter by: draft, needs_approval, scheduled, sending, sent, error | +| `limit` | number | No | Maximum number of posts to return \(default 20\) | +| `after` | string | No | Pagination cursor from a previous page \(pageInfo.endCursor\) | +| `sortBy` | string | No | Field to sort by: dueAt or createdAt \(default dueAt\) | +| `sortDirection` | string | No | Sort direction: asc or desc \(default asc\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `posts` | array | Posts matching the filters | +| ↳ `id` | string | Post ID | +| ↳ `text` | string | Post text content | +| ↳ `status` | string | Post status \(draft, needs_approval, scheduled, sending, sent, error\) | +| ↳ `via` | string | How the post was created \(buffer, network, api\) | +| ↳ `channelId` | string | Channel the post belongs to | +| ↳ `channelService` | string | Social network of the channel | +| ↳ `schedulingType` | string | How the post publishes \(automatic or notification\) | +| ↳ `shareMode` | string | Share mode used for the post | +| ↳ `isCustomScheduled` | boolean | Whether the post has a custom schedule | +| ↳ `sharedNow` | boolean | Whether the post was shared immediately | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last update timestamp \(ISO 8601\) | +| ↳ `dueAt` | string | Scheduled publish time \(ISO 8601\) | +| ↳ `sentAt` | string | Publish timestamp \(ISO 8601\) | +| ↳ `externalLink` | string | Link to the published post on the social network | +| ↳ `error` | object | Publishing error details when the post failed | +| ↳ `message` | string | Error message | +| ↳ `supportUrl` | string | Support article URL | +| ↳ `rawError` | string | Raw error from the network | +| ↳ `assets` | array | Media attached to the post | +| ↳ `id` | string | Asset ID | +| ↳ `type` | string | Asset type | +| ↳ `mimeType` | string | MIME type of the asset | +| ↳ `source` | string | Source URL of the asset | +| ↳ `thumbnail` | string | Thumbnail URL of the asset | +| `pageInfo` | object | Pagination info for fetching the next page | +| ↳ `hasNextPage` | boolean | Whether more results are available | +| ↳ `endCursor` | string | Cursor to pass as "after" for the next page | + +### `buffer_get_post` + +Get a single Buffer post by ID, including its status, schedule, and media + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `postId` | string | Yes | ID of the post to fetch | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `post` | object | The requested post | +| ↳ `id` | string | Post ID | +| ↳ `text` | string | Post text content | +| ↳ `status` | string | Post status \(draft, needs_approval, scheduled, sending, sent, error\) | +| ↳ `via` | string | How the post was created \(buffer, network, api\) | +| ↳ `channelId` | string | Channel the post belongs to | +| ↳ `channelService` | string | Social network of the channel | +| ↳ `schedulingType` | string | How the post publishes \(automatic or notification\) | +| ↳ `shareMode` | string | Share mode used for the post | +| ↳ `isCustomScheduled` | boolean | Whether the post has a custom schedule | +| ↳ `sharedNow` | boolean | Whether the post was shared immediately | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last update timestamp \(ISO 8601\) | +| ↳ `dueAt` | string | Scheduled publish time \(ISO 8601\) | +| ↳ `sentAt` | string | Publish timestamp \(ISO 8601\) | +| ↳ `externalLink` | string | Link to the published post on the social network | +| ↳ `error` | object | Publishing error details when the post failed | +| ↳ `message` | string | Error message | +| ↳ `supportUrl` | string | Support article URL | +| ↳ `rawError` | string | Raw error from the network | +| ↳ `assets` | array | Media attached to the post | +| ↳ `id` | string | Asset ID | +| ↳ `type` | string | Asset type | +| ↳ `mimeType` | string | MIME type of the asset | +| ↳ `source` | string | Source URL of the asset | +| ↳ `thumbnail` | string | Thumbnail URL of the asset | + +### `buffer_delete_post` + +Delete a Buffer post by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `postId` | string | Yes | ID of the post to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the post was deleted | +| `id` | string | ID of the deleted post | + +### `buffer_get_channels` + +List the social media channels connected to a Buffer organization, including their channel IDs (needed to create posts) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `organizationId` | string | Yes | Buffer organization ID \(find it with the Get Account operation\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `channels` | array | Channels connected to the organization | +| ↳ `id` | string | Channel ID | +| ↳ `name` | string | Channel name | +| ↳ `displayName` | string | Channel display name | +| ↳ `service` | string | Social network \(instagram, linkedin, twitter, ...\) | +| ↳ `serviceId` | string | ID of the account on the social network | +| ↳ `avatar` | string | Channel avatar URL | +| ↳ `timezone` | string | Channel timezone | +| ↳ `type` | string | Channel type \(page, profile, business, ...\) | +| ↳ `isQueuePaused` | boolean | Whether the posting queue is paused | +| ↳ `isDisconnected` | boolean | Whether the channel needs reconnection | +| ↳ `organizationId` | string | Organization the channel belongs to | + +### `buffer_create_idea` + +Save a content idea to a Buffer organization for later drafting and scheduling + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `organizationId` | string | Yes | Buffer organization ID \(find it with the Get Account operation\) | +| `text` | string | Yes | Text content of the idea | +| `title` | string | No | Optional title for the idea | +| `groupId` | string | No | Optional idea group \(board column\) to place the idea in | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `idea` | object | The created idea | +| ↳ `id` | string | Idea ID | +| ↳ `organizationId` | string | Organization the idea belongs to | +| ↳ `groupId` | string | Idea group ID | +| ↳ `title` | string | Idea title | +| ↳ `text` | string | Idea text content | + +### `buffer_get_account` + +Get the authenticated Buffer account, including its organizations and their IDs (needed for channel and post operations) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `account` | object | The authenticated Buffer account | +| ↳ `id` | string | Account ID | +| ↳ `email` | string | Account email | +| ↳ `name` | string | Account holder name | +| ↳ `timezone` | string | Account timezone | +| ↳ `organizations` | array | Organizations the account belongs to | +| ↳ `id` | string | Organization ID | +| ↳ `name` | string | Organization name | +| ↳ `channelCount` | number | Number of connected channels | +| ↳ `ownerEmail` | string | Email of the organization owner | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 85ab2e676bc..c0f2d18cf59 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -25,6 +25,7 @@ "brex", "brightdata", "browser_use", + "buffer", "calcom", "calendly", "circleback", diff --git a/apps/sim/app/api/tools/buffer/create-post/route.ts b/apps/sim/app/api/tools/buffer/create-post/route.ts new file mode 100644 index 00000000000..ecdf6fcf066 --- /dev/null +++ b/apps/sim/app/api/tools/buffer/create-post/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { bufferCreatePostContract } from '@/lib/api/contracts/tools/buffer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { forwardPostMutation } from '@/app/api/tools/buffer/server-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('BufferCreatePostAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Buffer create post attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + bufferCreatePostContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + return await forwardPostMutation({ + apiKey: body.apiKey, + channelId: body.channelId, + text: body.text, + mode: body.mode, + schedulingType: body.schedulingType, + dueAt: body.dueAt, + saveToDraft: body.saveToDraft, + media: body.media, + mediaAltText: body.mediaAltText, + userId: authResult.userId, + requestId, + logger, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to create post') + logger.error(`[${requestId}] Buffer create post failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/buffer/edit-post/route.ts b/apps/sim/app/api/tools/buffer/edit-post/route.ts new file mode 100644 index 00000000000..d8f2c675170 --- /dev/null +++ b/apps/sim/app/api/tools/buffer/edit-post/route.ts @@ -0,0 +1,69 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { bufferEditPostContract } from '@/lib/api/contracts/tools/buffer' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { forwardPostMutation } from '@/app/api/tools/buffer/server-utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('BufferEditPostAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Buffer edit post attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + const parsed = await parseRequest( + bufferEditPostContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + return await forwardPostMutation({ + apiKey: body.apiKey, + postId: body.postId, + text: body.text, + mode: body.mode, + schedulingType: body.schedulingType, + dueAt: body.dueAt, + saveToDraft: body.saveToDraft, + media: body.media, + mediaAltText: body.mediaAltText, + userId: authResult.userId, + requestId, + logger, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to edit post') + logger.error(`[${requestId}] Buffer edit post failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/buffer/server-utils.ts b/apps/sim/app/api/tools/buffer/server-utils.ts new file mode 100644 index 00000000000..c99be75cad7 --- /dev/null +++ b/apps/sim/app/api/tools/buffer/server-utils.ts @@ -0,0 +1,217 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { NextResponse } from 'next/server' +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' +import { + BUFFER_API_URL, + BUFFER_POST_SELECTION, + bufferHeaders, + mapBufferPost, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' + +const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.m4v', '.webm', '.avi'] + +const CREATE_POST_MUTATION = ` + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + __typename + ... on PostActionSuccess { + post { + ${BUFFER_POST_SELECTION} + } + } + ... on MutationError { + message + } + } + } +` + +const EDIT_POST_MUTATION = ` + mutation EditPost($input: EditPostInput!) { + editPost(input: $input) { + __typename + ... on PostActionSuccess { + post { + ${BUFFER_POST_SELECTION} + } + } + ... on MutationError { + message + } + } + } +` + +interface ResolveMediaAssetOptions { + media: RawFileInput | string + mediaAltText?: string | null + userId: string + requestId: string + logger: Logger +} + +interface ResolvedMediaAsset { + asset?: Record + errorResponse?: NextResponse +} + +/** + * Returns true when the media should be attached as a video asset, based on + * the file's MIME type or, failing that, the URL/file extension. + */ +function isVideoMedia(mimeType: string | undefined, pathOrName: string): boolean { + if (mimeType?.startsWith('video/')) return true + if (mimeType?.startsWith('image/')) return false + const lowered = pathOrName.toLowerCase().split('?')[0] + return VIDEO_EXTENSIONS.some((extension) => lowered.endsWith(extension)) +} + +/** + * Resolves a media input (uploaded file, file reference, or external URL) to a + * Buffer AssetInput. Buffer downloads assets from publicly accessible URLs, so + * stored files are verified for access and resolved to short-lived presigned + * URLs. + */ +export async function resolveMediaAsset( + options: ResolveMediaAssetOptions +): Promise { + const { media, mediaAltText, userId, requestId, logger } = options + + const isFileInput = typeof media === 'object' + const resolution = await resolveFileInputToUrl({ + file: isFileInput ? media : undefined, + filePath: isFileInput ? undefined : media, + userId, + requestId, + logger, + }) + if (resolution.error || !resolution.fileUrl) { + return { + errorResponse: NextResponse.json( + { success: false, error: resolution.error?.message || 'Failed to resolve media file' }, + { status: resolution.error?.status || 400 } + ), + } + } + + const mimeType = isFileInput ? media.type : undefined + const pathOrName = isFileInput ? media.name || '' : media + if (isVideoMedia(mimeType, pathOrName)) { + return { asset: { video: { url: resolution.fileUrl } } } + } + + const image: Record = { url: resolution.fileUrl } + if (mediaAltText?.trim()) { + image.metadata = { altText: mediaAltText.trim() } + } + return { asset: { image } } +} + +interface ExecutePostMutationOptions { + apiKey: string + mutation: typeof CREATE_POST_MUTATION | typeof EDIT_POST_MUTATION + input: Record + requestId: string + logger: Logger +} + +/** + * Executes a createPost/editPost mutation against the Buffer GraphQL API and + * maps the PostActionPayload union onto the route's response envelope. + */ +async function executePostMutation(options: ExecutePostMutationOptions): Promise { + const { apiKey, mutation, input, requestId, logger } = options + + let result: Record + try { + const response = await fetch(BUFFER_API_URL, { + method: 'POST', + headers: bufferHeaders(apiKey), + body: JSON.stringify({ query: mutation, variables: { input } }), + }) + const data = await parseBufferGraphQLResponse(response) + result = data.createPost ?? data.editPost + } catch (error) { + const message = getErrorMessage(error, 'Buffer API request failed') + logger.error(`[${requestId}] Buffer post mutation failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 502 }) + } + + if (result?.__typename !== 'PostActionSuccess') { + const message = result?.message || 'Buffer rejected the post' + logger.warn(`[${requestId}] Buffer rejected post mutation`, { + typename: result?.__typename, + error: message, + }) + return NextResponse.json({ success: false, error: message }, { status: 400 }) + } + + return NextResponse.json({ + success: true, + output: { post: mapBufferPost(result.post) }, + }) +} + +interface ForwardPostMutationOptions { + apiKey: string + postId?: string + channelId?: string + text?: string | null + mode: string + schedulingType: string + dueAt?: string | null + saveToDraft?: boolean | null + media?: RawFileInput | string | null + mediaAltText?: string | null + userId: string + requestId: string + logger: Logger +} + +/** + * Builds the CreatePostInput/EditPostInput from a validated route body + * (resolving media to a fetchable URL) and forwards the mutation to Buffer. + * Passing `postId` selects the editPost mutation; otherwise createPost runs. + */ +export async function forwardPostMutation( + options: ForwardPostMutationOptions +): Promise { + const { apiKey, postId, channelId, media, mediaAltText, userId, requestId, logger } = options + + const input: Record = { + mode: options.mode, + schedulingType: options.schedulingType, + } + if (postId) { + input.id = postId + } else { + input.channelId = channelId + input.assets = [] + } + if (options.text != null && options.text !== '') input.text = options.text + if (options.dueAt) input.dueAt = options.dueAt + if (options.saveToDraft != null) input.saveToDraft = options.saveToDraft + + if (media) { + const { asset, errorResponse } = await resolveMediaAsset({ + media, + mediaAltText, + userId, + requestId, + logger, + }) + if (errorResponse) return errorResponse + input.assets = [asset] + } + + return executePostMutation({ + apiKey, + mutation: postId ? EDIT_POST_MUTATION : CREATE_POST_MUTATION, + input, + requestId, + logger, + }) +} diff --git a/apps/sim/blocks/blocks/buffer.ts b/apps/sim/blocks/blocks/buffer.ts new file mode 100644 index 00000000000..31fdc6c15cc --- /dev/null +++ b/apps/sim/blocks/blocks/buffer.ts @@ -0,0 +1,449 @@ +import { BufferIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { BufferPostResponse } from '@/tools/buffer/types' + +const POST_EDIT_OPS = ['create_post', 'edit_post'] + +export const BufferBlock: BlockConfig = { + type: 'buffer', + name: 'Buffer', + description: 'Schedule and publish social media posts across connected channels', + longDescription: + 'Integrate Buffer into your workflow. Create, schedule, edit, and delete posts across connected social channels (Instagram, LinkedIn, X, Facebook, TikTok, and more), attach images or videos, browse channels, and capture content ideas using the Buffer API.', + docsLink: 'https://docs.sim.ai/integrations/buffer', + category: 'tools', + integrationType: IntegrationType.Marketing, + bgColor: '#FFFFFF', + icon: BufferIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create Post', id: 'create_post' }, + { label: 'Edit Post', id: 'edit_post' }, + { label: 'Get Posts', id: 'get_posts' }, + { label: 'Get Post', id: 'get_post' }, + { label: 'Delete Post', id: 'delete_post' }, + { label: 'Get Channels', id: 'get_channels' }, + { label: 'Create Idea', id: 'create_idea' }, + { label: 'Get Account', id: 'get_account' }, + ], + value: () => 'create_post', + }, + + // Channel to post to + { + id: 'channelId', + title: 'Channel ID', + type: 'short-input', + placeholder: 'Find channel IDs with the Get Channels operation', + condition: { field: 'operation', value: 'create_post' }, + required: { field: 'operation', value: 'create_post' }, + }, + + // Post identifier (edit/get/delete) + { + id: 'postId', + title: 'Post ID', + type: 'short-input', + condition: { field: 'operation', value: ['edit_post', 'get_post', 'delete_post'] }, + required: { field: 'operation', value: ['edit_post', 'get_post', 'delete_post'] }, + }, + + // Organization scope (list/idea operations) + { + id: 'organizationId', + title: 'Organization ID', + type: 'short-input', + placeholder: 'Find organization IDs with the Get Account operation', + condition: { field: 'operation', value: ['get_posts', 'get_channels', 'create_idea'] }, + required: { field: 'operation', value: ['get_posts', 'get_channels', 'create_idea'] }, + }, + + // Post / idea content + { + id: 'text', + title: 'Text', + type: 'long-input', + placeholder: 'What would you like to share?', + condition: { field: 'operation', value: [...POST_EDIT_OPS, 'create_idea'] }, + required: { field: 'operation', value: 'create_idea' }, + }, + { + id: 'mode', + title: 'Share Mode', + type: 'dropdown', + options: [ + { label: 'Add to queue', id: 'addToQueue' }, + { label: 'Share next', id: 'shareNext' }, + { label: 'Share now', id: 'shareNow' }, + { label: 'Custom schedule', id: 'customScheduled' }, + ], + value: () => 'addToQueue', + condition: { field: 'operation', value: POST_EDIT_OPS }, + required: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'dueAt', + title: 'Publish Time', + type: 'short-input', + placeholder: 'ISO 8601 timestamp, e.g. 2026-08-01T15:00:00Z', + condition: { + field: 'operation', + value: POST_EDIT_OPS, + and: { field: 'mode', value: 'customScheduled' }, + }, + required: { + field: 'operation', + value: POST_EDIT_OPS, + and: { field: 'mode', value: 'customScheduled' }, + }, + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp (e.g. 2026-08-01T15:00:00Z) for when the post should publish. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + + // Media attachment (basic upload / advanced reference or URL) + { + id: 'mediaUpload', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'media', + acceptedTypes: 'image/png,image/jpeg,image/gif,image/webp,video/mp4,video/quicktime', + mode: 'basic', + multiple: false, + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'mediaRef', + title: 'Media', + type: 'short-input', + canonicalParamId: 'media', + placeholder: 'Public image/video URL or a file reference from a previous block', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'mediaAltText', + title: 'Media Alt Text', + type: 'short-input', + placeholder: 'Describe the attached image', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'schedulingType', + title: 'Scheduling Type', + type: 'dropdown', + options: [ + { label: 'Automatic (Buffer publishes)', id: 'automatic' }, + { label: 'Notification (mobile reminder)', id: 'notification' }, + ], + value: () => 'automatic', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + { + id: 'saveToDraft', + title: 'Save as Draft', + type: 'switch', + mode: 'advanced', + condition: { field: 'operation', value: POST_EDIT_OPS }, + }, + + // Get Posts filters + { + id: 'channelIds', + title: 'Channel IDs', + type: 'short-input', + placeholder: 'Comma-separated channel IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'status', + title: 'Status Filter', + type: 'short-input', + placeholder: 'e.g. scheduled,sent (draft, needs_approval, scheduled, sending, sent, error)', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: 'Maximum posts to return (default 20)', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'after', + title: 'Cursor', + type: 'short-input', + placeholder: 'pageInfo.endCursor from a previous page', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Due date', id: 'dueAt' }, + { label: 'Created date', id: 'createdAt' }, + ], + value: () => 'dueAt', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + { + id: 'sortDirection', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Ascending', id: 'asc' }, + { label: 'Descending', id: 'desc' }, + ], + value: () => 'asc', + mode: 'advanced', + condition: { field: 'operation', value: 'get_posts' }, + }, + + // Idea fields + { + id: 'title', + title: 'Idea Title', + type: 'short-input', + placeholder: 'Optional title for the idea', + condition: { field: 'operation', value: 'create_idea' }, + }, + { + id: 'groupId', + title: 'Idea Group ID', + type: 'short-input', + placeholder: 'Optional idea group (board column)', + mode: 'advanced', + condition: { field: 'operation', value: 'create_idea' }, + }, + + // Credential + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Buffer API key', + password: true, + required: true, + }, + ], + + tools: { + access: [ + 'buffer_create_post', + 'buffer_edit_post', + 'buffer_get_posts', + 'buffer_get_post', + 'buffer_delete_post', + 'buffer_get_channels', + 'buffer_create_idea', + 'buffer_get_account', + ], + config: { + tool: (params) => `buffer_${params.operation}`, + params: (params) => { + const result: Record = {} + for (const [key, value] of Object.entries(params)) { + if (key === 'media') continue + if (value === undefined || value === null || value === '') continue + if (key === 'limit') { + const limit = Number(value) + if (Number.isFinite(limit)) result.limit = limit + continue + } + result[key] = value + } + + // Collapse basic/advanced media inputs into a single file reference, + // passing plain URL strings (advanced mode) through untouched. JSON-ish + // strings that normalize to nothing (e.g. "[]" from an empty file + // reference) are dropped rather than treated as URLs. + const media = params.media + const normalizedMedia = normalizeFileInput(media, { single: true }) + if (normalizedMedia) { + result.media = normalizedMedia + } else if (typeof media === 'string') { + const trimmed = media.trim() + const looksLikeJson = + trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed === 'null' + if (trimmed !== '' && !looksLikeJson) result.media = trimmed + } + + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Buffer API key' }, + channelId: { type: 'string', description: 'Channel to create the post for' }, + postId: { type: 'string', description: 'Post ID' }, + organizationId: { type: 'string', description: 'Buffer organization ID' }, + text: { type: 'string', description: 'Post or idea text content' }, + mode: { + type: 'string', + description: 'Share mode (addToQueue, shareNext, shareNow, customScheduled)', + }, + schedulingType: { type: 'string', description: 'Scheduling type (automatic or notification)' }, + dueAt: { type: 'string', description: 'Publish time (ISO 8601)' }, + saveToDraft: { type: 'boolean', description: 'Save the post as a draft' }, + media: { type: 'string', description: 'Image or video attachment (file or public URL)' }, + mediaAltText: { type: 'string', description: 'Alt text for an attached image' }, + channelIds: { type: 'string', description: 'Comma-separated channel IDs filter' }, + status: { type: 'string', description: 'Comma-separated post status filter' }, + limit: { type: 'number', description: 'Maximum posts to return' }, + after: { type: 'string', description: 'Pagination cursor' }, + sortBy: { type: 'string', description: 'Sort field (dueAt or createdAt)' }, + sortDirection: { type: 'string', description: 'Sort direction (asc or desc)' }, + title: { type: 'string', description: 'Idea title' }, + groupId: { type: 'string', description: 'Idea group ID' }, + }, + + outputs: { + post: { type: 'json', description: 'A single post' }, + posts: { type: 'json', description: 'List of posts' }, + pageInfo: { type: 'json', description: 'Pagination info for the next page' }, + channels: { type: 'json', description: 'List of connected channels' }, + account: { type: 'json', description: 'Account details with organizations' }, + idea: { type: 'json', description: 'The created idea' }, + deleted: { type: 'boolean', description: 'Whether the post was deleted' }, + id: { type: 'string', description: 'ID of the deleted post' }, + }, +} + +export const BufferBlockMeta = { + tags: ['marketing', 'scheduling', 'automation'], + url: 'https://buffer.com', + templates: [ + { + icon: BufferIcon, + title: 'Blog post to social queue', + prompt: + 'Build a workflow that takes a blog post URL and summary, writes a short social caption for it, and adds a Buffer post to the queue for each connected channel returned by Get Channels.', + modules: ['agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation'], + }, + { + icon: BufferIcon, + title: 'Weekly content calendar', + prompt: + "Create a workflow that reads next week's content calendar from a table and creates a custom-scheduled Buffer post for each row with its channel, caption, and publish time, then writes the new post IDs back to the table.", + modules: ['tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'scheduling'], + }, + { + icon: BufferIcon, + title: 'Image post from generated art', + prompt: + 'Build a workflow that generates an on-brand image with an AI image model, writes a matching caption, and creates a Buffer post with the image attached, scheduled for tomorrow morning.', + modules: ['agent', 'files', 'workflows'], + category: 'marketing', + tags: ['marketing', 'image-generation'], + }, + { + icon: BufferIcon, + title: 'Failed post alert to Slack', + prompt: + 'Create a scheduled workflow that lists Buffer posts with status error, and for each failed post sends a Slack alert with the channel, the post text, and the publishing error message so the team can fix and reschedule it.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: BufferIcon, + title: 'Daily queue health check', + prompt: + 'Build a scheduled daily workflow that gets all Buffer channels, flags any with a paused queue or disconnected account, counts scheduled posts per channel for the next 3 days, and emails a digest highlighting channels with an empty queue.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'reporting'], + }, + { + icon: BufferIcon, + title: 'Capture ideas from Slack', + prompt: + 'Create a workflow triggered by a Slack message in the content-ideas channel that cleans up the message text and saves it as a Buffer idea with a short title so the marketing team can draft it later.', + modules: ['agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation'], + alsoIntegrations: ['slack'], + }, + { + icon: BufferIcon, + title: 'Product launch announcement', + prompt: + 'Build a workflow that takes launch notes, writes a tailored announcement per social network, and shares a Buffer post immediately on every connected channel, then reports the external links of the published posts.', + modules: ['agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation'], + }, + { + icon: BufferIcon, + title: 'Evergreen content recycler', + prompt: + 'Create a scheduled weekly workflow that lists Buffer posts sent more than 90 days ago, picks the top evergreen ones, refreshes their captions, and re-adds them to the queue as new posts.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'scheduling', 'automation'], + }, + ], + skills: [ + { + name: 'schedule-social-post', + description: + 'Create and schedule a Buffer post on a channel — queued, shared immediately, or at a specific time. Use to publish content to social media.', + content: + '# Schedule Social Post\n\nPublish or schedule a post on a connected Buffer channel.\n\n## Steps\n1. If the channel ID is unknown, use Get Account to find the organization ID, then Get Channels to list channels and pick the right one.\n2. Use Create Post with the channel ID and the post text.\n3. Pick the share mode: addToQueue (next open queue slot), shareNext (front of the queue), shareNow (publish immediately), or customScheduled with an ISO 8601 dueAt time.\n4. Optionally attach an image or video (uploaded file or public URL) and set alt text for images.\n\n## Output\nReturn the new post id, its status, the channel, and the scheduled time (dueAt) so the user knows when it will publish.', + }, + { + name: 'post-with-media', + description: + 'Attach an image or video to a Buffer post from an uploaded file, a previous block output, or a public URL. Use for visual content.', + content: + '# Post With Media\n\nCreate a Buffer post with an image or video attachment.\n\n## Steps\n1. Provide the media as an uploaded file, a file reference from a previous block (e.g. an image-generation output), or a publicly accessible URL.\n2. Use Create Post with the channel ID, caption text, and the media input — Buffer detects images vs videos automatically.\n3. For images, set alt text to keep posts accessible.\n4. Choose the share mode and, for customScheduled, the dueAt publish time.\n\n## Output\nReturn the post id, status, and the attached asset details (type and source URL) from the response.', + }, + { + name: 'review-post-queue', + description: + 'List scheduled, draft, sent, or failed posts across channels with pagination. Use to inspect what is coming up or what already went out.', + content: + '# Review Post Queue\n\nInspect posts in a Buffer organization.\n\n## Steps\n1. Use Get Account to find the organization ID if unknown.\n2. Use Get Posts with the organization ID; filter by comma-separated channel IDs and statuses (draft, needs_approval, scheduled, sending, sent, error).\n3. Sort by dueAt ascending to see the upcoming schedule, or createdAt descending for recent activity.\n4. Page through results with pageInfo.endCursor passed as the cursor until hasNextPage is false.\n\n## Output\nReturn a concise list per post: id, channel service, status, due/sent time, and a text preview. Note any posts in error status.', + }, + { + name: 'fix-failed-posts', + description: + 'Find Buffer posts that failed to publish, read their errors, and reschedule them. Use to recover from publishing failures.', + content: + "# Fix Failed Posts\n\nRecover posts that failed to publish.\n\n## Steps\n1. Use Get Posts filtered to status error to find failed posts.\n2. Read each post's error message (and support URL) to understand why it failed.\n3. If the content needs adjusting, use Edit Post to update the text or media.\n4. Reschedule by editing the post with mode addToQueue or customScheduled and a new dueAt, or delete it with Delete Post if it is no longer wanted.\n\n## Output\nReturn a summary per failed post: the failure reason and the action taken (rescheduled, edited, or deleted).", + }, + { + name: 'capture-content-idea', + description: + "Save a content idea to Buffer's ideas board for later drafting. Use when inspiration arrives before it is ready to schedule.", + content: + "# Capture Content Idea\n\nSave rough content to Buffer's ideas board.\n\n## Steps\n1. Use Get Account to find the organization ID if unknown.\n2. Use Create Idea with the organization ID and the idea text; add a short title so it is easy to scan on the board.\n3. Optionally place it in a specific idea group (board column) with the group ID.\n\n## Output\nReturn the idea id and title, and confirm it is saved on the ideas board ready to be drafted into posts.", + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 7934e50a8f1..f416aa0f25c 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -24,6 +24,7 @@ import { BrandfetchBlock, BrandfetchBlockMeta } from '@/blocks/blocks/brandfetch import { BrexBlock, BrexBlockMeta } from '@/blocks/blocks/brex' import { BrightDataBlock, BrightDataBlockMeta } from '@/blocks/blocks/brightdata' import { BrowserUseBlock, BrowserUseBlockMeta } from '@/blocks/blocks/browser_use' +import { BufferBlock, BufferBlockMeta } from '@/blocks/blocks/buffer' import { CalComBlock, CalComBlockMeta } from '@/blocks/blocks/calcom' import { CalendlyBlock, CalendlyBlockMeta } from '@/blocks/blocks/calendly' import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' @@ -362,6 +363,7 @@ export const BLOCK_REGISTRY: Record = { brex: BrexBlock, brightdata: BrightDataBlock, browser_use: BrowserUseBlock, + buffer: BufferBlock, calcom: CalComBlock, calendly: CalendlyBlock, chat_trigger: ChatTriggerBlock, @@ -680,6 +682,7 @@ export const BLOCK_META_REGISTRY: Record = { brex: BrexBlockMeta, brightdata: BrightDataBlockMeta, browser_use: BrowserUseBlockMeta, + buffer: BufferBlockMeta, calcom: CalComBlockMeta, calendly: CalendlyBlockMeta, circleback: CirclebackBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f1fb7446eeb..9efe9fc6db3 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2533,6 +2533,24 @@ l57 -85 -48 -124 c-203 -517 -79 -930 346 -1155 159 -85 441 -71 585 28 l111 ) } +export function BufferIcon(props: SVGProps) { + return ( + + + + ) +} + export function Mem0Icon(props: SVGProps) { return ( { + validateDueAt(body, ctx) + if (!body.text?.trim() && !body.media) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['text'], + message: 'Either text or media is required', + }) + } + }) + +export type BufferCreatePostBody = z.input + +export const bufferCreatePostContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/buffer/create-post', + body: bufferCreatePostBodySchema, + response: { mode: 'json', schema: postRouteResponseSchema }, +}) + +export const bufferEditPostBodySchema = z + .object({ + ...postSharedFields, + postId: z.string().min(1, 'postId is required'), + }) + .superRefine(validateDueAt) + +export type BufferEditPostBody = z.input + +export const bufferEditPostContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/buffer/edit-post', + body: bufferEditPostBodySchema, + response: { mode: 'json', schema: postRouteResponseSchema }, +}) diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index f7c5ddf8d19..a42329e5ba5 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -27,6 +27,7 @@ import { BrexIcon, BrightDataIcon, BrowserUseIcon, + BufferIcon, CalComIcon, CalendlyIcon, CirclebackIcon, @@ -262,6 +263,7 @@ export const blockTypeToIconMap: Record = { brex: BrexIcon, brightdata: BrightDataIcon, browser_use: BrowserUseIcon, + buffer: BufferIcon, calcom: CalComIcon, calendly: CalendlyIcon, circleback: CirclebackIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 98849d6a9a4..6236dd29ad7 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -2603,6 +2603,57 @@ "integrationType": "ai", "tags": ["web-scraping", "automation", "agentic"] }, + { + "type": "buffer", + "slug": "buffer", + "name": "Buffer", + "description": "Schedule and publish social media posts across connected channels", + "longDescription": "Integrate Buffer into your workflow. Create, schedule, edit, and delete posts across connected social channels (Instagram, LinkedIn, X, Facebook, TikTok, and more), attach images or videos, browse channels, and capture content ideas using the Buffer API.", + "bgColor": "#FFFFFF", + "iconName": "BufferIcon", + "docsUrl": "https://docs.sim.ai/integrations/buffer", + "operations": [ + { + "name": "Create Post", + "description": "Create a post in Buffer for a channel — add it to the queue, share it immediately, schedule it for a specific time, or save it as a draft, optionally with an image or video attachment" + }, + { + "name": "Edit Post", + "description": "Edit an existing Buffer post — update its text, schedule, or media. Attaching new media replaces the existing attachments" + }, + { + "name": "Get Posts", + "description": "List posts in a Buffer organization, optionally filtered by channel and status (draft, needs_approval, scheduled, sending, sent, error)" + }, + { + "name": "Get Post", + "description": "Get a single Buffer post by ID, including its status, schedule, and media" + }, + { + "name": "Delete Post", + "description": "Delete a Buffer post by ID" + }, + { + "name": "Get Channels", + "description": "List the social media channels connected to a Buffer organization, including their channel IDs (needed to create posts)" + }, + { + "name": "Create Idea", + "description": "Save a content idea to a Buffer organization for later drafting and scheduling" + }, + { + "name": "Get Account", + "description": "Get the authenticated Buffer account, including its organizations and their IDs (needed for channel and post operations)" + } + ], + "operationCount": 8, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "marketing", + "tags": ["marketing", "scheduling", "automation"] + }, { "type": "calcom", "slug": "cal-com", diff --git a/apps/sim/tools/buffer/create_idea.ts b/apps/sim/tools/buffer/create_idea.ts new file mode 100644 index 00000000000..22ebb73f19f --- /dev/null +++ b/apps/sim/tools/buffer/create_idea.ts @@ -0,0 +1,136 @@ +import { + BUFFER_API_URL, + type BufferCreateIdeaParams, + type BufferIdea, + type BufferIdeaResponse, + bufferHeaders, + IDEA_OUTPUT_PROPERTIES, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const IDEA_SELECTION = ` + id + organizationId + groupId + content { + title + text + } +` + +const CREATE_IDEA_MUTATION = ` + mutation CreateIdea($input: CreateIdeaInput!) { + createIdea(input: $input) { + __typename + ... on Idea { + ${IDEA_SELECTION} + } + ... on IdeaResponse { + idea { + ${IDEA_SELECTION} + } + } + ... on MutationError { + message + } + } + } +` + +/** + * Maps a raw GraphQL Idea node onto the stable output shape. + */ +function mapIdea(idea: Record): BufferIdea { + return { + id: idea.id, + organizationId: idea.organizationId ?? '', + groupId: idea.groupId ?? null, + title: idea.content?.title ?? null, + text: idea.content?.text ?? null, + } +} + +export const bufferCreateIdeaTool: ToolConfig = { + id: 'buffer_create_idea', + name: 'Buffer Create Idea', + description: 'Save a content idea to a Buffer organization for later drafting and scheduling', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Buffer organization ID (find it with the Get Account operation)', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Text content of the idea', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional title for the idea', + }, + groupId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional idea group (board column) to place the idea in', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => { + const content: Record = { text: params.text } + if (params.title) content.title = params.title + + const input: Record = { + organizationId: params.organizationId, + content, + } + if (params.groupId) input.group = { groupId: params.groupId } + + return { + query: CREATE_IDEA_MUTATION, + variables: { input }, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + const result = data.createIdea + const idea = result?.__typename === 'Idea' ? result : result?.idea + if (!idea?.id) { + throw new Error(result?.message || 'Failed to create idea') + } + return { + success: true, + output: { + idea: mapIdea(idea), + }, + } + }, + + outputs: { + idea: { + type: 'object', + description: 'The created idea', + properties: IDEA_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/create_post.ts b/apps/sim/tools/buffer/create_post.ts new file mode 100644 index 00000000000..14688169a7c --- /dev/null +++ b/apps/sim/tools/buffer/create_post.ts @@ -0,0 +1,110 @@ +import { + type BufferCreatePostParams, + type BufferPostResponse, + POST_OUTPUT_PROPERTIES, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +export const bufferCreatePostTool: ToolConfig = { + id: 'buffer_create_post', + name: 'Buffer Create Post', + description: + 'Create a post in Buffer for a channel — add it to the queue, share it immediately, schedule it for a specific time, or save it as a draft, optionally with an image or video attachment', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + channelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Channel to create the post for (find it with the Get Channels operation)', + }, + text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Text content of the post (required unless media is attached)', + }, + mode: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'How to share the post: addToQueue, shareNext, shareNow, or customScheduled (requires dueAt)', + }, + schedulingType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'How the post publishes: automatic (Buffer publishes it) or notification (you get a mobile reminder)', + }, + dueAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Publish time as an ISO 8601 timestamp (required when mode is customScheduled)', + }, + saveToDraft: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Save the post as a draft instead of scheduling it', + }, + media: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: + 'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL', + }, + mediaAltText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Alt text for an attached image', + }, + }, + + request: { + url: '/api/tools/buffer/create-post', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + channelId: params.channelId, + text: params.text, + mode: params.mode, + schedulingType: params.schedulingType, + dueAt: params.dueAt, + saveToDraft: params.saveToDraft, + media: params.media, + mediaAltText: params.mediaAltText, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to create post') + } + return { + success: true, + output: data.output, + } + }, + + outputs: { + post: { + type: 'object', + description: 'The created post', + properties: POST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/delete_post.ts b/apps/sim/tools/buffer/delete_post.ts new file mode 100644 index 00000000000..cc966295549 --- /dev/null +++ b/apps/sim/tools/buffer/delete_post.ts @@ -0,0 +1,76 @@ +import { + BUFFER_API_URL, + type BufferDeletePostParams, + type BufferDeletePostResponse, + bufferHeaders, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const DELETE_POST_MUTATION = ` + mutation DeletePost($input: DeletePostInput!) { + deletePost(input: $input) { + __typename + ... on DeletePostSuccess { + id + } + ... on VoidMutationError { + message + } + } + } +` + +export const bufferDeletePostTool: ToolConfig = { + id: 'buffer_delete_post', + name: 'Buffer Delete Post', + description: 'Delete a Buffer post by ID', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + postId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the post to delete', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => ({ + query: DELETE_POST_MUTATION, + variables: { + input: { id: params.postId }, + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + const result = data.deletePost + if (result?.__typename !== 'DeletePostSuccess') { + throw new Error(result?.message || 'Failed to delete post') + } + return { + success: true, + output: { + deleted: true, + id: result.id, + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the post was deleted' }, + id: { type: 'string', description: 'ID of the deleted post' }, + }, +} diff --git a/apps/sim/tools/buffer/edit_post.ts b/apps/sim/tools/buffer/edit_post.ts new file mode 100644 index 00000000000..76f5b8d2ba2 --- /dev/null +++ b/apps/sim/tools/buffer/edit_post.ts @@ -0,0 +1,110 @@ +import { + type BufferEditPostParams, + type BufferPostResponse, + POST_OUTPUT_PROPERTIES, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +export const bufferEditPostTool: ToolConfig = { + id: 'buffer_edit_post', + name: 'Buffer Edit Post', + description: + 'Edit an existing Buffer post — update its text, schedule, or media. Attaching new media replaces the existing attachments', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + postId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the post to edit', + }, + text: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New text content of the post', + }, + mode: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'How to share the post: addToQueue, shareNext, shareNow, or customScheduled (requires dueAt)', + }, + schedulingType: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'How the post publishes: automatic (Buffer publishes it) or notification (you get a mobile reminder)', + }, + dueAt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Publish time as an ISO 8601 timestamp (required when mode is customScheduled)', + }, + saveToDraft: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Save the post as a draft instead of scheduling it', + }, + media: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: + 'Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments', + }, + mediaAltText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Alt text for an attached image', + }, + }, + + request: { + url: '/api/tools/buffer/edit-post', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + apiKey: params.apiKey, + postId: params.postId, + text: params.text, + mode: params.mode, + schedulingType: params.schedulingType, + dueAt: params.dueAt, + saveToDraft: params.saveToDraft, + media: params.media, + mediaAltText: params.mediaAltText, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to edit post') + } + return { + success: true, + output: data.output, + } + }, + + outputs: { + post: { + type: 'object', + description: 'The updated post', + properties: POST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/get_account.ts b/apps/sim/tools/buffer/get_account.ts new file mode 100644 index 00000000000..a8fafc62360 --- /dev/null +++ b/apps/sim/tools/buffer/get_account.ts @@ -0,0 +1,80 @@ +import { + ACCOUNT_OUTPUT_PROPERTIES, + BUFFER_API_URL, + type BufferAccountResponse, + type BufferGetAccountParams, + bufferHeaders, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const GET_ACCOUNT_QUERY = ` + query GetAccount { + account { + id + email + name + timezone + organizations { + id + name + channelCount + ownerEmail + } + } + } +` + +export const bufferGetAccountTool: ToolConfig = { + id: 'buffer_get_account', + name: 'Buffer Get Account', + description: + 'Get the authenticated Buffer account, including its organizations and their IDs (needed for channel and post operations)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: () => ({ query: GET_ACCOUNT_QUERY }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + const account = data.account + return { + success: true, + output: { + account: { + id: account.id, + email: account.email ?? '', + name: account.name ?? null, + timezone: account.timezone ?? null, + organizations: (account.organizations ?? []).map((org: Record) => ({ + id: org.id, + name: org.name ?? '', + channelCount: org.channelCount ?? 0, + ownerEmail: org.ownerEmail ?? '', + })), + }, + }, + } + }, + + outputs: { + account: { + type: 'object', + description: 'The authenticated Buffer account', + properties: ACCOUNT_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/get_channels.ts b/apps/sim/tools/buffer/get_channels.ts new file mode 100644 index 00000000000..a735ba56287 --- /dev/null +++ b/apps/sim/tools/buffer/get_channels.ts @@ -0,0 +1,81 @@ +import { + BUFFER_API_URL, + type BufferChannelsResponse, + type BufferGetChannelsParams, + bufferHeaders, + CHANNEL_OUTPUT_PROPERTIES, + mapBufferChannel, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const GET_CHANNELS_QUERY = ` + query GetChannels($input: ChannelsInput!) { + channels(input: $input) { + id + name + displayName + service + serviceId + avatar + timezone + type + isQueuePaused + isDisconnected + organizationId + } + } +` + +export const bufferGetChannelsTool: ToolConfig = { + id: 'buffer_get_channels', + name: 'Buffer Get Channels', + description: + 'List the social media channels connected to a Buffer organization, including their channel IDs (needed to create posts)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Buffer organization ID (find it with the Get Account operation)', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => ({ + query: GET_CHANNELS_QUERY, + variables: { + input: { organizationId: params.organizationId }, + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + return { + success: true, + output: { + channels: (data.channels ?? []).map(mapBufferChannel), + }, + } + }, + + outputs: { + channels: { + type: 'array', + description: 'Channels connected to the organization', + items: { type: 'object', properties: CHANNEL_OUTPUT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/buffer/get_post.ts b/apps/sim/tools/buffer/get_post.ts new file mode 100644 index 00000000000..f779458eeb1 --- /dev/null +++ b/apps/sim/tools/buffer/get_post.ts @@ -0,0 +1,74 @@ +import { + BUFFER_API_URL, + BUFFER_POST_SELECTION, + type BufferGetPostParams, + type BufferPostResponse, + bufferHeaders, + mapBufferPost, + POST_OUTPUT_PROPERTIES, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const GET_POST_QUERY = ` + query GetPost($input: PostInput!) { + post(input: $input) { + ${BUFFER_POST_SELECTION} + } + } +` + +export const bufferGetPostTool: ToolConfig = { + id: 'buffer_get_post', + name: 'Buffer Get Post', + description: 'Get a single Buffer post by ID, including its status, schedule, and media', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + postId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the post to fetch', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => ({ + query: GET_POST_QUERY, + variables: { + input: { id: params.postId }, + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + if (!data.post) { + throw new Error('Post not found') + } + return { + success: true, + output: { + post: mapBufferPost(data.post), + }, + } + }, + + outputs: { + post: { + type: 'object', + description: 'The requested post', + properties: POST_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/get_posts.ts b/apps/sim/tools/buffer/get_posts.ts new file mode 100644 index 00000000000..ee1eb736a8f --- /dev/null +++ b/apps/sim/tools/buffer/get_posts.ts @@ -0,0 +1,176 @@ +import { + BUFFER_API_URL, + BUFFER_POST_SELECTION, + BUFFER_POST_STATUSES, + type BufferGetPostsParams, + type BufferPostsResponse, + bufferHeaders, + mapBufferPost, + PAGE_INFO_OUTPUT_PROPERTIES, + POST_OUTPUT_PROPERTIES, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const GET_POSTS_QUERY = ` + query GetPosts($input: PostsInput!, $first: Int, $after: String) { + posts(input: $input, first: $first, after: $after) { + edges { + node { + ${BUFFER_POST_SELECTION} + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +const DEFAULT_LIMIT = 20 + +/** + * Splits a comma-separated string into trimmed, non-empty values. + */ +function splitCommaSeparated(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) +} + +export const bufferGetPostsTool: ToolConfig = { + id: 'buffer_get_posts', + name: 'Buffer Get Posts', + description: + 'List posts in a Buffer organization, optionally filtered by channel and status (draft, needs_approval, scheduled, sending, sent, error)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Buffer organization ID (find it with the Get Account operation)', + }, + channelIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated channel IDs to filter by', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated statuses to filter by: draft, needs_approval, scheduled, sending, sent, error', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of posts to return (default 20)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous page (pageInfo.endCursor)', + }, + sortBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Field to sort by: dueAt or createdAt (default dueAt)', + }, + sortDirection: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort direction: asc or desc (default asc)', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => { + const input: Record = { organizationId: params.organizationId } + + const filter: Record = {} + if (params.channelIds) { + const channelIds = splitCommaSeparated(params.channelIds) + if (channelIds.length > 0) filter.channelIds = channelIds + } + if (params.status) { + const statuses = splitCommaSeparated(params.status) + const invalid = statuses.filter( + (status) => !(BUFFER_POST_STATUSES as readonly string[]).includes(status) + ) + if (invalid.length > 0) { + throw new Error( + `Invalid post status "${invalid[0]}". Valid statuses: ${BUFFER_POST_STATUSES.join(', ')}` + ) + } + if (statuses.length > 0) filter.status = statuses + } + if (Object.keys(filter).length > 0) input.filter = filter + + const sortBy = params.sortBy || 'dueAt' + if (!['dueAt', 'createdAt'].includes(sortBy)) { + throw new Error('sortBy must be either "dueAt" or "createdAt"') + } + const sortDirection = params.sortDirection || 'asc' + if (!['asc', 'desc'].includes(sortDirection)) { + throw new Error('sortDirection must be either "asc" or "desc"') + } + input.sort = [{ field: sortBy, direction: sortDirection }] + + return { + query: GET_POSTS_QUERY, + variables: { + input, + first: params.limit ?? DEFAULT_LIMIT, + after: params.after || null, + }, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + const posts = data.posts ?? {} + return { + success: true, + output: { + posts: (posts.edges ?? []).map((edge: Record) => mapBufferPost(edge.node)), + pageInfo: { + hasNextPage: posts.pageInfo?.hasNextPage ?? false, + endCursor: posts.pageInfo?.endCursor ?? null, + }, + }, + } + }, + + outputs: { + posts: { + type: 'array', + description: 'Posts matching the filters', + items: { type: 'object', properties: POST_OUTPUT_PROPERTIES }, + }, + pageInfo: { + type: 'object', + description: 'Pagination info for fetching the next page', + properties: PAGE_INFO_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/index.ts b/apps/sim/tools/buffer/index.ts new file mode 100644 index 00000000000..719ea99f16e --- /dev/null +++ b/apps/sim/tools/buffer/index.ts @@ -0,0 +1,9 @@ +export { bufferCreateIdeaTool } from '@/tools/buffer/create_idea' +export { bufferCreatePostTool } from '@/tools/buffer/create_post' +export { bufferDeletePostTool } from '@/tools/buffer/delete_post' +export { bufferEditPostTool } from '@/tools/buffer/edit_post' +export { bufferGetAccountTool } from '@/tools/buffer/get_account' +export { bufferGetChannelsTool } from '@/tools/buffer/get_channels' +export { bufferGetPostTool } from '@/tools/buffer/get_post' +export { bufferGetPostsTool } from '@/tools/buffer/get_posts' +export * from '@/tools/buffer/types' diff --git a/apps/sim/tools/buffer/types.ts b/apps/sim/tools/buffer/types.ts new file mode 100644 index 00000000000..e23aed44b12 --- /dev/null +++ b/apps/sim/tools/buffer/types.ts @@ -0,0 +1,443 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** Single GraphQL endpoint for the Buffer API. */ +export const BUFFER_API_URL = 'https://api.buffer.com' + +/** Valid values for the `mode` argument of createPost/editPost. */ +export const BUFFER_SHARE_MODES = [ + 'addToQueue', + 'shareNext', + 'shareNow', + 'customScheduled', +] as const + +/** Valid values for the `schedulingType` argument of createPost/editPost. */ +export const BUFFER_SCHEDULING_TYPES = ['automatic', 'notification'] as const + +/** Valid post status filter values for the posts query. */ +export const BUFFER_POST_STATUSES = [ + 'draft', + 'needs_approval', + 'scheduled', + 'sending', + 'sent', + 'error', +] as const + +export type BufferShareMode = (typeof BUFFER_SHARE_MODES)[number] +export type BufferSchedulingType = (typeof BUFFER_SCHEDULING_TYPES)[number] +export type BufferPostStatus = (typeof BUFFER_POST_STATUSES)[number] + +/** Every Buffer tool authenticates with the account API key as a Bearer token. */ +interface BufferBaseParams { + apiKey: string +} + +/** + * Builds the standard headers shared by every Buffer GraphQL request. + */ +export function bufferHeaders(apiKey: string): Record { + return { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + } +} + +/** + * Parses a Buffer GraphQL response and returns its `data` payload. + * Throws with a readable message when the transport fails or the + * response carries top-level GraphQL errors. + */ +export async function parseBufferGraphQLResponse(response: Response): Promise> { + let payload: { data?: Record; errors?: Array<{ message?: string }> } + try { + payload = await response.json() + } catch { + throw new Error(`Buffer API error (HTTP ${response.status})`) + } + if (payload.errors?.length) { + throw new Error(payload.errors[0]?.message || 'Buffer API error') + } + if (!payload.data) { + throw new Error(`Buffer API returned no data (HTTP ${response.status})`) + } + return payload.data +} + +/** GraphQL selection set shared by every operation that returns a Post. */ +export const BUFFER_POST_SELECTION = ` + id + text + status + via + channelId + channelService + schedulingType + shareMode + isCustomScheduled + sharedNow + createdAt + updatedAt + dueAt + sentAt + externalLink + error { + message + supportUrl + rawError + } + assets { + id + type + mimeType + source + thumbnail + } +` + +// region Shared object shapes + +export interface BufferAsset { + id: string | null + type: string + mimeType: string + source: string + thumbnail: string +} + +export interface BufferPostError { + message: string + supportUrl: string | null + rawError: string | null +} + +export interface BufferPost { + id: string + text: string + status: string + via: string + channelId: string + channelService: string + schedulingType: string | null + shareMode: string + isCustomScheduled: boolean + sharedNow: boolean + createdAt: string + updatedAt: string + dueAt: string | null + sentAt: string | null + externalLink: string | null + error: BufferPostError | null + assets: BufferAsset[] +} + +export interface BufferChannel { + id: string + name: string + displayName: string | null + service: string + serviceId: string + avatar: string + timezone: string + type: string + isQueuePaused: boolean + isDisconnected: boolean + organizationId: string +} + +export interface BufferOrganization { + id: string + name: string + channelCount: number + ownerEmail: string +} + +export interface BufferAccount { + id: string + email: string + name: string | null + timezone: string | null + organizations: BufferOrganization[] +} + +export interface BufferIdea { + id: string + organizationId: string + groupId: string | null + title: string | null + text: string | null +} + +export interface BufferPageInfo { + hasNextPage: boolean + endCursor: string | null +} + +// endregion + +// region Response mappers + +/** + * Maps a raw GraphQL Post node onto the stable output shape. + */ +export function mapBufferPost(post: Record): BufferPost { + return { + id: post.id, + text: post.text ?? '', + status: post.status ?? '', + via: post.via ?? '', + channelId: post.channelId ?? '', + channelService: post.channelService ?? '', + schedulingType: post.schedulingType ?? null, + shareMode: post.shareMode ?? '', + isCustomScheduled: post.isCustomScheduled ?? false, + sharedNow: post.sharedNow ?? false, + createdAt: post.createdAt ?? '', + updatedAt: post.updatedAt ?? '', + dueAt: post.dueAt ?? null, + sentAt: post.sentAt ?? null, + externalLink: post.externalLink ?? null, + error: post.error + ? { + message: post.error.message ?? '', + supportUrl: post.error.supportUrl ?? null, + rawError: post.error.rawError ?? null, + } + : null, + assets: (post.assets ?? []).map((asset: Record) => ({ + id: asset.id ?? null, + type: asset.type ?? '', + mimeType: asset.mimeType ?? '', + source: asset.source ?? '', + thumbnail: asset.thumbnail ?? '', + })), + } +} + +/** + * Maps a raw GraphQL Channel node onto the stable output shape. + */ +export function mapBufferChannel(channel: Record): BufferChannel { + return { + id: channel.id, + name: channel.name ?? '', + displayName: channel.displayName ?? null, + service: channel.service ?? '', + serviceId: channel.serviceId ?? '', + avatar: channel.avatar ?? '', + timezone: channel.timezone ?? '', + type: channel.type ?? '', + isQueuePaused: channel.isQueuePaused ?? false, + isDisconnected: channel.isDisconnected ?? false, + organizationId: channel.organizationId ?? '', + } +} + +// endregion + +// region Output property maps + +export const POST_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Post ID' }, + text: { type: 'string', description: 'Post text content' }, + status: { + type: 'string', + description: 'Post status (draft, needs_approval, scheduled, sending, sent, error)', + }, + via: { type: 'string', description: 'How the post was created (buffer, network, api)' }, + channelId: { type: 'string', description: 'Channel the post belongs to' }, + channelService: { type: 'string', description: 'Social network of the channel' }, + schedulingType: { + type: 'string', + nullable: true, + description: 'How the post publishes (automatic or notification)', + }, + shareMode: { type: 'string', description: 'Share mode used for the post' }, + isCustomScheduled: { type: 'boolean', description: 'Whether the post has a custom schedule' }, + sharedNow: { type: 'boolean', description: 'Whether the post was shared immediately' }, + createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)' }, + updatedAt: { type: 'string', description: 'Last update timestamp (ISO 8601)' }, + dueAt: { type: 'string', nullable: true, description: 'Scheduled publish time (ISO 8601)' }, + sentAt: { type: 'string', nullable: true, description: 'Publish timestamp (ISO 8601)' }, + externalLink: { + type: 'string', + nullable: true, + description: 'Link to the published post on the social network', + }, + error: { + type: 'object', + nullable: true, + description: 'Publishing error details when the post failed', + properties: { + message: { type: 'string', description: 'Error message' }, + supportUrl: { type: 'string', nullable: true, description: 'Support article URL' }, + rawError: { type: 'string', nullable: true, description: 'Raw error from the network' }, + }, + }, + assets: { + type: 'array', + description: 'Media attached to the post', + items: { + type: 'object', + properties: { + id: { type: 'string', nullable: true, description: 'Asset ID' }, + type: { type: 'string', description: 'Asset type' }, + mimeType: { type: 'string', description: 'MIME type of the asset' }, + source: { type: 'string', description: 'Source URL of the asset' }, + thumbnail: { type: 'string', description: 'Thumbnail URL of the asset' }, + }, + }, + }, +} + +export const CHANNEL_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Channel ID' }, + name: { type: 'string', description: 'Channel name' }, + displayName: { type: 'string', nullable: true, description: 'Channel display name' }, + service: { type: 'string', description: 'Social network (instagram, linkedin, twitter, ...)' }, + serviceId: { type: 'string', description: 'ID of the account on the social network' }, + avatar: { type: 'string', description: 'Channel avatar URL' }, + timezone: { type: 'string', description: 'Channel timezone' }, + type: { type: 'string', description: 'Channel type (page, profile, business, ...)' }, + isQueuePaused: { type: 'boolean', description: 'Whether the posting queue is paused' }, + isDisconnected: { type: 'boolean', description: 'Whether the channel needs reconnection' }, + organizationId: { type: 'string', description: 'Organization the channel belongs to' }, +} + +export const ACCOUNT_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Account ID' }, + email: { type: 'string', description: 'Account email' }, + name: { type: 'string', nullable: true, description: 'Account holder name' }, + timezone: { type: 'string', nullable: true, description: 'Account timezone' }, + organizations: { + type: 'array', + description: 'Organizations the account belongs to', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Organization ID' }, + name: { type: 'string', description: 'Organization name' }, + channelCount: { type: 'number', description: 'Number of connected channels' }, + ownerEmail: { type: 'string', description: 'Email of the organization owner' }, + }, + }, + }, +} + +export const IDEA_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Idea ID' }, + organizationId: { type: 'string', description: 'Organization the idea belongs to' }, + groupId: { type: 'string', nullable: true, description: 'Idea group ID' }, + title: { type: 'string', nullable: true, description: 'Idea title' }, + text: { type: 'string', nullable: true, description: 'Idea text content' }, +} + +export const PAGE_INFO_OUTPUT_PROPERTIES: Record = { + hasNextPage: { type: 'boolean', description: 'Whether more results are available' }, + endCursor: { + type: 'string', + nullable: true, + description: 'Cursor to pass as "after" for the next page', + }, +} + +// endregion + +// region Tool params + +export interface BufferCreatePostParams extends BufferBaseParams { + channelId: string + text?: string + mode: BufferShareMode + schedulingType: BufferSchedulingType + dueAt?: string + saveToDraft?: boolean + media?: unknown + mediaAltText?: string +} + +export interface BufferEditPostParams extends BufferBaseParams { + postId: string + text?: string + mode: BufferShareMode + schedulingType: BufferSchedulingType + dueAt?: string + saveToDraft?: boolean + media?: unknown + mediaAltText?: string +} + +export interface BufferDeletePostParams extends BufferBaseParams { + postId: string +} + +export interface BufferGetPostParams extends BufferBaseParams { + postId: string +} + +export interface BufferGetPostsParams extends BufferBaseParams { + organizationId: string + channelIds?: string + status?: string + limit?: number + after?: string + sortBy?: string + sortDirection?: string +} + +export interface BufferGetChannelsParams extends BufferBaseParams { + organizationId: string +} + +export type BufferGetAccountParams = BufferBaseParams + +export interface BufferCreateIdeaParams extends BufferBaseParams { + organizationId: string + text: string + title?: string + groupId?: string +} + +// endregion + +// region Tool responses + +export interface BufferPostResponse extends ToolResponse { + output: { + post: BufferPost + } +} + +export interface BufferDeletePostResponse extends ToolResponse { + output: { + deleted: boolean + id: string + } +} + +export interface BufferPostsResponse extends ToolResponse { + output: { + posts: BufferPost[] + pageInfo: BufferPageInfo + } +} + +export interface BufferChannelsResponse extends ToolResponse { + output: { + channels: BufferChannel[] + } +} + +export interface BufferAccountResponse extends ToolResponse { + output: { + account: BufferAccount + } +} + +export interface BufferIdeaResponse extends ToolResponse { + output: { + idea: BufferIdea + } +} + +// endregion diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e6f33250a0d..88c425bd492 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -391,6 +391,16 @@ import { brightDataSyncScrapeTool, } from '@/tools/brightdata' import { browserUseRunTaskTool } from '@/tools/browser_use' +import { + bufferCreateIdeaTool, + bufferCreatePostTool, + bufferDeletePostTool, + bufferEditPostTool, + bufferGetAccountTool, + bufferGetChannelsTool, + bufferGetPostsTool, + bufferGetPostTool, +} from '@/tools/buffer' import { calcomCancelBookingTool, calcomConfirmBookingTool, @@ -4718,6 +4728,14 @@ export const tools: Record = { box_sign_cancel_request: boxSignCancelRequestTool, box_sign_resend_request: boxSignResendRequestTool, browser_use_run_task: browserUseRunTaskTool, + buffer_create_idea: bufferCreateIdeaTool, + buffer_create_post: bufferCreatePostTool, + buffer_delete_post: bufferDeletePostTool, + buffer_edit_post: bufferEditPostTool, + buffer_get_account: bufferGetAccountTool, + buffer_get_channels: bufferGetChannelsTool, + buffer_get_post: bufferGetPostTool, + buffer_get_posts: bufferGetPostsTool, openai_embeddings: openAIEmbeddingsTool, http_request: httpRequestTool, webhook_request: webhookRequestTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index a7d3983e36d..ea6557a151d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 947, - zodRoutes: 947, + totalRoutes: 949, + zodRoutes: 949, nonZodRoutes: 0, } as const From 9f02915af7c1ee1ef700c6015b3c9133eb89522a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:16:26 -0700 Subject: [PATCH 2/6] fix(buffer): return clear tool error when account lookup yields no account --- apps/sim/tools/buffer/get_account.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/tools/buffer/get_account.ts b/apps/sim/tools/buffer/get_account.ts index a8fafc62360..6f4e971fddc 100644 --- a/apps/sim/tools/buffer/get_account.ts +++ b/apps/sim/tools/buffer/get_account.ts @@ -51,6 +51,9 @@ export const bufferGetAccountTool: ToolConfig { const data = await parseBufferGraphQLResponse(response) const account = data.account + if (!account) { + throw new Error('Buffer account not found — check that the API key is valid') + } return { success: true, output: { From bdedf135561b6106e54453177fa7922822eefc52 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:28:18 -0700 Subject: [PATCH 3/6] fix(buffer): default schedulingType server-side so basic-mode blocks never fail validation --- apps/sim/lib/api/contracts/tools/buffer.ts | 2 +- apps/sim/tools/buffer/create_post.ts | 4 ++-- apps/sim/tools/buffer/edit_post.ts | 4 ++-- apps/sim/tools/buffer/types.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/api/contracts/tools/buffer.ts b/apps/sim/lib/api/contracts/tools/buffer.ts index ef3361c747b..0cbd891c57d 100644 --- a/apps/sim/lib/api/contracts/tools/buffer.ts +++ b/apps/sim/lib/api/contracts/tools/buffer.ts @@ -54,7 +54,7 @@ const postSharedFields = { apiKey: z.string().min(1, 'API key is required'), text: z.string().max(50000, 'text is too long').optional().nullable(), mode: z.enum(['addToQueue', 'shareNext', 'shareNow', 'customScheduled']), - schedulingType: z.enum(['automatic', 'notification']), + schedulingType: z.enum(['automatic', 'notification']).default('automatic'), dueAt: z .string() .datetime({ offset: true, message: 'dueAt must be an ISO 8601 timestamp' }) diff --git a/apps/sim/tools/buffer/create_post.ts b/apps/sim/tools/buffer/create_post.ts index 14688169a7c..b94601b9c61 100644 --- a/apps/sim/tools/buffer/create_post.ts +++ b/apps/sim/tools/buffer/create_post.ts @@ -40,10 +40,10 @@ export const bufferCreatePostTool: ToolConfig Date: Mon, 13 Jul 2026 11:38:02 -0700 Subject: [PATCH 4/6] fix(buffer): probe Content-Type for extensionless media URLs so videos are not sent as images --- apps/sim/app/api/tools/buffer/server-utils.ts | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/tools/buffer/server-utils.ts b/apps/sim/app/api/tools/buffer/server-utils.ts index c99be75cad7..0fe264bfbaf 100644 --- a/apps/sim/app/api/tools/buffer/server-utils.ts +++ b/apps/sim/app/api/tools/buffer/server-utils.ts @@ -1,6 +1,10 @@ import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { NextResponse } from 'next/server' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' import { @@ -12,6 +16,8 @@ import { } from '@/tools/buffer/types' const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.m4v', '.webm', '.avi'] +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp'] +const MEDIA_PROBE_TIMEOUT_MS = 5000 const CREATE_POST_MUTATION = ` mutation CreatePost($input: CreatePostInput!) { @@ -59,14 +65,52 @@ interface ResolvedMediaAsset { } /** - * Returns true when the media should be attached as a video asset, based on - * the file's MIME type or, failing that, the URL/file extension. + * Classifies media by extension: 'video', 'image', or null when the + * path/URL has no recognizable media extension. */ -function isVideoMedia(mimeType: string | undefined, pathOrName: string): boolean { - if (mimeType?.startsWith('video/')) return true - if (mimeType?.startsWith('image/')) return false +function mediaKindFromExtension(pathOrName: string): 'image' | 'video' | null { const lowered = pathOrName.toLowerCase().split('?')[0] - return VIDEO_EXTENSIONS.some((extension) => lowered.endsWith(extension)) + if (VIDEO_EXTENSIONS.some((extension) => lowered.endsWith(extension))) return 'video' + if (IMAGE_EXTENSIONS.some((extension) => lowered.endsWith(extension))) return 'image' + return null +} + +/** + * Determines whether media should be attached as a video or image asset. + * Prefers the file's MIME type, then the path/URL extension, and for + * extensionless URLs falls back to a DNS-pinned HEAD probe of the resolved + * URL's Content-Type. Defaults to image when nothing is conclusive. + */ +async function resolveMediaKind( + mimeType: string | undefined, + pathOrName: string, + fileUrl: string, + requestId: string, + logger: Logger +): Promise<'image' | 'video'> { + if (mimeType?.startsWith('video/')) return 'video' + if (mimeType?.startsWith('image/')) return 'image' + + const extensionKind = mediaKindFromExtension(pathOrName) + if (extensionKind) return extensionKind + + try { + const validation = await validateUrlWithDNS(fileUrl, 'media') + if (validation.isValid && validation.resolvedIP) { + const probe = await secureFetchWithPinnedIP(fileUrl, validation.resolvedIP, { + method: 'HEAD', + timeout: MEDIA_PROBE_TIMEOUT_MS, + }) + const contentType = probe.headers.get('content-type') || '' + if (contentType.startsWith('video/')) return 'video' + if (contentType.startsWith('image/')) return 'image' + } + } catch (error) { + logger.warn(`[${requestId}] Media content-type probe failed, defaulting to image`, { + error: getErrorMessage(error, 'probe failed'), + }) + } + return 'image' } /** @@ -99,7 +143,8 @@ export async function resolveMediaAsset( const mimeType = isFileInput ? media.type : undefined const pathOrName = isFileInput ? media.name || '' : media - if (isVideoMedia(mimeType, pathOrName)) { + const kind = await resolveMediaKind(mimeType, pathOrName, resolution.fileUrl, requestId, logger) + if (kind === 'video') { return { asset: { video: { url: resolution.fileUrl } } } } From d3d0a975a524a6db466639342c84f6c802ac9f9d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 11:51:15 -0700 Subject: [PATCH 5/6] feat(buffer): add get_ideas and get_idea_groups tools, harden media URL classification --- .../content/docs/en/integrations/buffer.mdx | 51 ++++++++- apps/sim/app/api/tools/buffer/server-utils.ts | 2 +- apps/sim/blocks/blocks/buffer.ts | 36 ++++-- apps/sim/lib/integrations/integrations.json | 10 +- apps/sim/tools/buffer/create_idea.ts | 32 +----- apps/sim/tools/buffer/get_idea_groups.ts | 79 +++++++++++++ apps/sim/tools/buffer/get_ideas.ts | 106 ++++++++++++++++++ apps/sim/tools/buffer/index.ts | 2 + apps/sim/tools/buffer/types.ts | 59 ++++++++++ apps/sim/tools/registry.ts | 4 + 10 files changed, 340 insertions(+), 41 deletions(-) create mode 100644 apps/sim/tools/buffer/get_idea_groups.ts create mode 100644 apps/sim/tools/buffer/get_ideas.ts diff --git a/apps/docs/content/docs/en/integrations/buffer.mdx b/apps/docs/content/docs/en/integrations/buffer.mdx index 4ae15ab1e5f..6cdb61a38e5 100644 --- a/apps/docs/content/docs/en/integrations/buffer.mdx +++ b/apps/docs/content/docs/en/integrations/buffer.mdx @@ -30,7 +30,7 @@ Create a post in Buffer for a channel — add it to the queue, share it immediat | `channelId` | string | Yes | Channel to create the post for \(find it with the Get Channels operation\) | | `text` | string | No | Text content of the post \(required unless media is attached\) | | `mode` | string | Yes | How to share the post: addToQueue, shareNext, shareNow, or customScheduled \(requires dueAt\) | -| `schedulingType` | string | Yes | How the post publishes: automatic \(Buffer publishes it\) or notification \(you get a mobile reminder\) | +| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) | | `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) | | `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it | | `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL | @@ -79,7 +79,7 @@ Edit an existing Buffer post — update its text, schedule, or media. Attaching | `postId` | string | Yes | ID of the post to edit | | `text` | string | No | New text content of the post | | `mode` | string | Yes | How to share the post: addToQueue, shareNext, shareNow, or customScheduled \(requires dueAt\) | -| `schedulingType` | string | Yes | How the post publishes: automatic \(Buffer publishes it\) or notification \(you get a mobile reminder\) | +| `schedulingType` | string | No | How the post publishes: automatic \(Buffer publishes it, default\) or notification \(you get a mobile reminder\) | | `dueAt` | string | No | Publish time as an ISO 8601 timestamp \(required when mode is customScheduled\) | | `saveToDraft` | boolean | No | Save the post as a draft instead of scheduling it | | `media` | file | No | Image or video to attach — an uploaded file, a file reference from a previous block, or a publicly accessible URL. Replaces existing attachments | @@ -280,6 +280,53 @@ Save a content idea to a Buffer organization for later drafting and scheduling | ↳ `title` | string | Idea title | | ↳ `text` | string | Idea text content | +### `buffer_get_ideas` + +List content ideas saved in a Buffer organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `organizationId` | string | Yes | Buffer organization ID \(find it with the Get Account operation\) | +| `limit` | number | No | Maximum number of ideas to return \(default 20\) | +| `after` | string | No | Pagination cursor from a previous page \(pageInfo.endCursor\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ideas` | array | Content ideas in the organization | +| ↳ `id` | string | Idea ID | +| ↳ `organizationId` | string | Organization the idea belongs to | +| ↳ `groupId` | string | Idea group ID | +| ↳ `title` | string | Idea title | +| ↳ `text` | string | Idea text content | +| `pageInfo` | object | Pagination info for fetching the next page | +| ↳ `hasNextPage` | boolean | Whether more results are available | +| ↳ `endCursor` | string | Cursor to pass as "after" for the next page | + +### `buffer_get_idea_groups` + +List idea groups (board columns) in a Buffer organization, including the group IDs used when creating ideas + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Buffer API key | +| `organizationId` | string | Yes | Buffer organization ID \(find it with the Get Account operation\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ideaGroups` | array | Idea groups \(board columns\) in the organization | +| ↳ `id` | string | Idea group ID | +| ↳ `name` | string | Idea group name | +| ↳ `isLocked` | boolean | Whether the group is locked | + ### `buffer_get_account` Get the authenticated Buffer account, including its organizations and their IDs (needed for channel and post operations) diff --git a/apps/sim/app/api/tools/buffer/server-utils.ts b/apps/sim/app/api/tools/buffer/server-utils.ts index 0fe264bfbaf..dc5c7fbd7da 100644 --- a/apps/sim/app/api/tools/buffer/server-utils.ts +++ b/apps/sim/app/api/tools/buffer/server-utils.ts @@ -69,7 +69,7 @@ interface ResolvedMediaAsset { * path/URL has no recognizable media extension. */ function mediaKindFromExtension(pathOrName: string): 'image' | 'video' | null { - const lowered = pathOrName.toLowerCase().split('?')[0] + const lowered = pathOrName.toLowerCase().split(/[?#]/)[0] if (VIDEO_EXTENSIONS.some((extension) => lowered.endsWith(extension))) return 'video' if (IMAGE_EXTENSIONS.some((extension) => lowered.endsWith(extension))) return 'image' return null diff --git a/apps/sim/blocks/blocks/buffer.ts b/apps/sim/blocks/blocks/buffer.ts index 31fdc6c15cc..0f04c953b08 100644 --- a/apps/sim/blocks/blocks/buffer.ts +++ b/apps/sim/blocks/blocks/buffer.ts @@ -32,6 +32,8 @@ export const BufferBlock: BlockConfig = { { label: 'Delete Post', id: 'delete_post' }, { label: 'Get Channels', id: 'get_channels' }, { label: 'Create Idea', id: 'create_idea' }, + { label: 'Get Ideas', id: 'get_ideas' }, + { label: 'Get Idea Groups', id: 'get_idea_groups' }, { label: 'Get Account', id: 'get_account' }, ], value: () => 'create_post', @@ -62,8 +64,14 @@ export const BufferBlock: BlockConfig = { title: 'Organization ID', type: 'short-input', placeholder: 'Find organization IDs with the Get Account operation', - condition: { field: 'operation', value: ['get_posts', 'get_channels', 'create_idea'] }, - required: { field: 'operation', value: ['get_posts', 'get_channels', 'create_idea'] }, + condition: { + field: 'operation', + value: ['get_posts', 'get_channels', 'create_idea', 'get_ideas', 'get_idea_groups'], + }, + required: { + field: 'operation', + value: ['get_posts', 'get_channels', 'create_idea', 'get_ideas', 'get_idea_groups'], + }, }, // Post / idea content @@ -181,9 +189,9 @@ export const BufferBlock: BlockConfig = { id: 'limit', title: 'Limit', type: 'short-input', - placeholder: 'Maximum posts to return (default 20)', + placeholder: 'Maximum results to return (default 20)', mode: 'advanced', - condition: { field: 'operation', value: 'get_posts' }, + condition: { field: 'operation', value: ['get_posts', 'get_ideas'] }, }, { id: 'after', @@ -191,7 +199,7 @@ export const BufferBlock: BlockConfig = { type: 'short-input', placeholder: 'pageInfo.endCursor from a previous page', mode: 'advanced', - condition: { field: 'operation', value: 'get_posts' }, + condition: { field: 'operation', value: ['get_posts', 'get_ideas'] }, }, { id: 'sortBy', @@ -255,6 +263,8 @@ export const BufferBlock: BlockConfig = { 'buffer_delete_post', 'buffer_get_channels', 'buffer_create_idea', + 'buffer_get_ideas', + 'buffer_get_idea_groups', 'buffer_get_account', ], config: { @@ -280,11 +290,15 @@ export const BufferBlock: BlockConfig = { const normalizedMedia = normalizeFileInput(media, { single: true }) if (normalizedMedia) { result.media = normalizedMedia - } else if (typeof media === 'string') { + } else if (typeof media === 'string' && media.trim() !== '') { const trimmed = media.trim() - const looksLikeJson = - trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed === 'null' - if (trimmed !== '' && !looksLikeJson) result.media = trimmed + let parsesAsJson = true + try { + JSON.parse(trimmed) + } catch { + parsesAsJson = false + } + if (!parsesAsJson) result.media = trimmed } return result @@ -325,6 +339,8 @@ export const BufferBlock: BlockConfig = { channels: { type: 'json', description: 'List of connected channels' }, account: { type: 'json', description: 'Account details with organizations' }, idea: { type: 'json', description: 'The created idea' }, + ideas: { type: 'json', description: 'List of content ideas' }, + ideaGroups: { type: 'json', description: 'List of idea groups (board columns)' }, deleted: { type: 'boolean', description: 'Whether the post was deleted' }, id: { type: 'string', description: 'ID of the deleted post' }, }, @@ -443,7 +459,7 @@ export const BufferBlockMeta = { description: "Save a content idea to Buffer's ideas board for later drafting. Use when inspiration arrives before it is ready to schedule.", content: - "# Capture Content Idea\n\nSave rough content to Buffer's ideas board.\n\n## Steps\n1. Use Get Account to find the organization ID if unknown.\n2. Use Create Idea with the organization ID and the idea text; add a short title so it is easy to scan on the board.\n3. Optionally place it in a specific idea group (board column) with the group ID.\n\n## Output\nReturn the idea id and title, and confirm it is saved on the ideas board ready to be drafted into posts.", + "# Capture Content Idea\n\nSave rough content to Buffer's ideas board.\n\n## Steps\n1. Use Get Account to find the organization ID if unknown.\n2. Use Create Idea with the organization ID and the idea text; add a short title so it is easy to scan on the board.\n3. Optionally place it in a specific idea group (board column) — use Get Idea Groups to find the group ID.\n\n## Output\nReturn the idea id and title, and confirm it is saved on the ideas board ready to be drafted into posts.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 6236dd29ad7..0ccfaf2549d 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -2641,12 +2641,20 @@ "name": "Create Idea", "description": "Save a content idea to a Buffer organization for later drafting and scheduling" }, + { + "name": "Get Ideas", + "description": "List content ideas saved in a Buffer organization" + }, + { + "name": "Get Idea Groups", + "description": "List idea groups (board columns) in a Buffer organization, including the group IDs used when creating ideas" + }, { "name": "Get Account", "description": "Get the authenticated Buffer account, including its organizations and their IDs (needed for channel and post operations)" } ], - "operationCount": 8, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/tools/buffer/create_idea.ts b/apps/sim/tools/buffer/create_idea.ts index 22ebb73f19f..a0a202fe1c7 100644 --- a/apps/sim/tools/buffer/create_idea.ts +++ b/apps/sim/tools/buffer/create_idea.ts @@ -1,34 +1,25 @@ import { BUFFER_API_URL, + BUFFER_IDEA_SELECTION, type BufferCreateIdeaParams, - type BufferIdea, type BufferIdeaResponse, bufferHeaders, IDEA_OUTPUT_PROPERTIES, + mapBufferIdea, parseBufferGraphQLResponse, } from '@/tools/buffer/types' import type { ToolConfig } from '@/tools/types' -const IDEA_SELECTION = ` - id - organizationId - groupId - content { - title - text - } -` - const CREATE_IDEA_MUTATION = ` mutation CreateIdea($input: CreateIdeaInput!) { createIdea(input: $input) { __typename ... on Idea { - ${IDEA_SELECTION} + ${BUFFER_IDEA_SELECTION} } ... on IdeaResponse { idea { - ${IDEA_SELECTION} + ${BUFFER_IDEA_SELECTION} } } ... on MutationError { @@ -38,19 +29,6 @@ const CREATE_IDEA_MUTATION = ` } ` -/** - * Maps a raw GraphQL Idea node onto the stable output shape. - */ -function mapIdea(idea: Record): BufferIdea { - return { - id: idea.id, - organizationId: idea.organizationId ?? '', - groupId: idea.groupId ?? null, - title: idea.content?.title ?? null, - text: idea.content?.text ?? null, - } -} - export const bufferCreateIdeaTool: ToolConfig = { id: 'buffer_create_idea', name: 'Buffer Create Idea', @@ -121,7 +99,7 @@ export const bufferCreateIdeaTool: ToolConfig = { + id: 'buffer_get_idea_groups', + name: 'Buffer Get Idea Groups', + description: + 'List idea groups (board columns) in a Buffer organization, including the group IDs used when creating ideas', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Buffer organization ID (find it with the Get Account operation)', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => ({ + query: GET_IDEA_GROUPS_QUERY, + variables: { + input: { organizationId: params.organizationId }, + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + return { + success: true, + output: { + ideaGroups: (data.ideaGroups ?? []).map((group: Record) => ({ + id: group.id, + name: group.name ?? '', + isLocked: group.isLocked ?? false, + })), + }, + } + }, + + outputs: { + ideaGroups: { + type: 'array', + description: 'Idea groups (board columns) in the organization', + items: { type: 'object', properties: IDEA_GROUP_OUTPUT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/buffer/get_ideas.ts b/apps/sim/tools/buffer/get_ideas.ts new file mode 100644 index 00000000000..6b88565e170 --- /dev/null +++ b/apps/sim/tools/buffer/get_ideas.ts @@ -0,0 +1,106 @@ +import { + BUFFER_API_URL, + BUFFER_IDEA_SELECTION, + type BufferGetIdeasParams, + type BufferIdeasResponse, + bufferHeaders, + IDEA_OUTPUT_PROPERTIES, + mapBufferIdea, + PAGE_INFO_OUTPUT_PROPERTIES, + parseBufferGraphQLResponse, +} from '@/tools/buffer/types' +import type { ToolConfig } from '@/tools/types' + +const GET_IDEAS_QUERY = ` + query GetIdeas($input: IdeasInput!, $first: Int, $after: String) { + ideas(input: $input, first: $first, after: $after) { + edges { + node { + ${BUFFER_IDEA_SELECTION} + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +const DEFAULT_LIMIT = 20 + +export const bufferGetIdeasTool: ToolConfig = { + id: 'buffer_get_ideas', + name: 'Buffer Get Ideas', + description: 'List content ideas saved in a Buffer organization', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Buffer API key', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Buffer organization ID (find it with the Get Account operation)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of ideas to return (default 20)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous page (pageInfo.endCursor)', + }, + }, + + request: { + url: BUFFER_API_URL, + method: 'POST', + headers: (params) => bufferHeaders(params.apiKey), + body: (params) => ({ + query: GET_IDEAS_QUERY, + variables: { + input: { organizationId: params.organizationId }, + first: params.limit ?? DEFAULT_LIMIT, + after: params.after || null, + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseBufferGraphQLResponse(response) + const ideas = data.ideas ?? {} + return { + success: true, + output: { + ideas: (ideas.edges ?? []).map((edge: Record) => mapBufferIdea(edge.node)), + pageInfo: { + hasNextPage: ideas.pageInfo?.hasNextPage ?? false, + endCursor: ideas.pageInfo?.endCursor ?? null, + }, + }, + } + }, + + outputs: { + ideas: { + type: 'array', + description: 'Content ideas in the organization', + items: { type: 'object', properties: IDEA_OUTPUT_PROPERTIES }, + }, + pageInfo: { + type: 'object', + description: 'Pagination info for fetching the next page', + properties: PAGE_INFO_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/buffer/index.ts b/apps/sim/tools/buffer/index.ts index 719ea99f16e..18bdabfedf3 100644 --- a/apps/sim/tools/buffer/index.ts +++ b/apps/sim/tools/buffer/index.ts @@ -4,6 +4,8 @@ export { bufferDeletePostTool } from '@/tools/buffer/delete_post' export { bufferEditPostTool } from '@/tools/buffer/edit_post' export { bufferGetAccountTool } from '@/tools/buffer/get_account' export { bufferGetChannelsTool } from '@/tools/buffer/get_channels' +export { bufferGetIdeaGroupsTool } from '@/tools/buffer/get_idea_groups' +export { bufferGetIdeasTool } from '@/tools/buffer/get_ideas' export { bufferGetPostTool } from '@/tools/buffer/get_post' export { bufferGetPostsTool } from '@/tools/buffer/get_posts' export * from '@/tools/buffer/types' diff --git a/apps/sim/tools/buffer/types.ts b/apps/sim/tools/buffer/types.ts index 06cd9b88ec8..88841bf9156 100644 --- a/apps/sim/tools/buffer/types.ts +++ b/apps/sim/tools/buffer/types.ts @@ -168,6 +168,12 @@ export interface BufferIdea { text: string | null } +export interface BufferIdeaGroup { + id: string + name: string + isLocked: boolean +} + export interface BufferPageInfo { hasNextPage: boolean endCursor: string | null @@ -177,6 +183,30 @@ export interface BufferPageInfo { // region Response mappers +/** GraphQL selection set shared by every operation that returns an Idea. */ +export const BUFFER_IDEA_SELECTION = ` + id + organizationId + groupId + content { + title + text + } +` + +/** + * Maps a raw GraphQL Idea node onto the stable output shape. + */ +export function mapBufferIdea(idea: Record): BufferIdea { + return { + id: idea.id, + organizationId: idea.organizationId ?? '', + groupId: idea.groupId ?? null, + title: idea.content?.title ?? null, + text: idea.content?.text ?? null, + } +} + /** * Maps a raw GraphQL Post node onto the stable output shape. */ @@ -332,6 +362,12 @@ export const IDEA_OUTPUT_PROPERTIES: Record = { text: { type: 'string', nullable: true, description: 'Idea text content' }, } +export const IDEA_GROUP_OUTPUT_PROPERTIES: Record = { + id: { type: 'string', description: 'Idea group ID' }, + name: { type: 'string', description: 'Idea group name' }, + isLocked: { type: 'boolean', description: 'Whether the group is locked' }, +} + export const PAGE_INFO_OUTPUT_PROPERTIES: Record = { hasNextPage: { type: 'boolean', description: 'Whether more results are available' }, endCursor: { @@ -398,6 +434,16 @@ export interface BufferCreateIdeaParams extends BufferBaseParams { groupId?: string } +export interface BufferGetIdeasParams extends BufferBaseParams { + organizationId: string + limit?: number + after?: string +} + +export interface BufferGetIdeaGroupsParams extends BufferBaseParams { + organizationId: string +} + // endregion // region Tool responses @@ -440,4 +486,17 @@ export interface BufferIdeaResponse extends ToolResponse { } } +export interface BufferIdeasResponse extends ToolResponse { + output: { + ideas: BufferIdea[] + pageInfo: BufferPageInfo + } +} + +export interface BufferIdeaGroupsResponse extends ToolResponse { + output: { + ideaGroups: BufferIdeaGroup[] + } +} + // endregion diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 88c425bd492..554beddea6d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -398,6 +398,8 @@ import { bufferEditPostTool, bufferGetAccountTool, bufferGetChannelsTool, + bufferGetIdeaGroupsTool, + bufferGetIdeasTool, bufferGetPostsTool, bufferGetPostTool, } from '@/tools/buffer' @@ -4734,6 +4736,8 @@ export const tools: Record = { buffer_edit_post: bufferEditPostTool, buffer_get_account: bufferGetAccountTool, buffer_get_channels: bufferGetChannelsTool, + buffer_get_idea_groups: bufferGetIdeaGroupsTool, + buffer_get_ideas: bufferGetIdeasTool, buffer_get_post: bufferGetPostTool, buffer_get_posts: bufferGetPostsTool, openai_embeddings: openAIEmbeddingsTool, From fa61c55ad61b8d5d0fd56a9440004412ee8a0bc8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 13 Jul 2026 12:00:03 -0700 Subject: [PATCH 6/6] fix(buffer): guard missing post on PostActionSuccess responses --- apps/sim/app/api/tools/buffer/server-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/buffer/server-utils.ts b/apps/sim/app/api/tools/buffer/server-utils.ts index dc5c7fbd7da..88e03314ad3 100644 --- a/apps/sim/app/api/tools/buffer/server-utils.ts +++ b/apps/sim/app/api/tools/buffer/server-utils.ts @@ -185,7 +185,7 @@ async function executePostMutation(options: ExecutePostMutationOptions): Promise return NextResponse.json({ success: false, error: message }, { status: 502 }) } - if (result?.__typename !== 'PostActionSuccess') { + if (result?.__typename !== 'PostActionSuccess' || !result.post) { const message = result?.message || 'Buffer rejected the post' logger.warn(`[${requestId}] Buffer rejected post mutation`, { typename: result?.__typename,