From b798afcb2d0474911c4aa07337baad494980e47e Mon Sep 17 00:00:00 2001 From: Rose Kamal Love <69139607+rosekamallove@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:39:14 +0530 Subject: [PATCH] feat(letmepost): add letmepost piece for social media publishing (#13801) Co-authored-by: sanket@activepieces.com --- bun.lock | 16 +- .../pieces/community/letmepost/.eslintrc.json | 18 ++ .../pieces/community/letmepost/package.json | 17 ++ .../pieces/community/letmepost/src/index.ts | 35 ++++ .../letmepost/src/lib/actions/get-post.ts | 35 ++++ .../src/lib/actions/list-accounts.ts | 37 ++++ .../letmepost/src/lib/actions/list-media.ts | 45 +++++ .../letmepost/src/lib/actions/publish-post.ts | 159 ++++++++++++++++++ .../letmepost/src/lib/common/auth.ts | 46 +++++ .../letmepost/src/lib/common/index.ts | 88 ++++++++++ .../src/lib/triggers/new-published-post.ts | 90 ++++++++++ .../letmepost/src/lib/triggers/post-event.ts | 147 ++++++++++++++++ .../pieces/community/letmepost/tsconfig.json | 19 +++ .../community/letmepost/tsconfig.lib.json | 15 ++ 14 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 packages/pieces/community/letmepost/.eslintrc.json create mode 100644 packages/pieces/community/letmepost/package.json create mode 100644 packages/pieces/community/letmepost/src/index.ts create mode 100644 packages/pieces/community/letmepost/src/lib/actions/get-post.ts create mode 100644 packages/pieces/community/letmepost/src/lib/actions/list-accounts.ts create mode 100644 packages/pieces/community/letmepost/src/lib/actions/list-media.ts create mode 100644 packages/pieces/community/letmepost/src/lib/actions/publish-post.ts create mode 100644 packages/pieces/community/letmepost/src/lib/common/auth.ts create mode 100644 packages/pieces/community/letmepost/src/lib/common/index.ts create mode 100644 packages/pieces/community/letmepost/src/lib/triggers/new-published-post.ts create mode 100644 packages/pieces/community/letmepost/src/lib/triggers/post-event.ts create mode 100644 packages/pieces/community/letmepost/tsconfig.json create mode 100644 packages/pieces/community/letmepost/tsconfig.lib.json diff --git a/bun.lock b/bun.lock index 7a212e727078..2e40c0c6d3f4 100644 --- a/bun.lock +++ b/bun.lock @@ -1900,7 +1900,7 @@ }, "packages/pieces/community/connectuc": { "name": "@activepieces/piece-connectuc", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", @@ -4150,6 +4150,16 @@ "tslib": "^2.3.0", }, }, + "packages/pieces/community/letmepost": { + "name": "@activepieces/piece-letmepost", + "version": "0.0.1", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + "@activepieces/shared": "workspace:*", + "tslib": "^2.3.0", + }, + }, "packages/pieces/community/lets-calendar": { "name": "@activepieces/piece-lets-calendar", "version": "0.0.6", @@ -9665,6 +9675,8 @@ "@activepieces/piece-lemon-squeezy": ["@activepieces/piece-lemon-squeezy@workspace:packages/pieces/community/lemon-squeezy"], + "@activepieces/piece-letmepost": ["@activepieces/piece-letmepost@workspace:packages/pieces/community/letmepost"], + "@activepieces/piece-lets-calendar": ["@activepieces/piece-lets-calendar@workspace:packages/pieces/community/lets-calendar"], "@activepieces/piece-letta": ["@activepieces/piece-letta@workspace:packages/pieces/community/letta"], @@ -16207,6 +16219,8 @@ "@activepieces/piece-lemon-squeezy/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@activepieces/piece-letmepost/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@activepieces/piece-lets-calendar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@activepieces/piece-letta/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], diff --git a/packages/pieces/community/letmepost/.eslintrc.json b/packages/pieces/community/letmepost/.eslintrc.json new file mode 100644 index 000000000000..632e9b0e2225 --- /dev/null +++ b/packages/pieces/community/letmepost/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/pieces/community/letmepost/package.json b/packages/pieces/community/letmepost/package.json new file mode 100644 index 000000000000..ab837542ad37 --- /dev/null +++ b/packages/pieces/community/letmepost/package.json @@ -0,0 +1,17 @@ +{ + "name": "@activepieces/piece-letmepost", + "version": "0.0.1", + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.lib.json && cp package.json dist/", + "lint": "eslint 'src/**/*.ts'" + }, + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + "@activepieces/shared": "workspace:*", + "tslib": "^2.3.0" + } +} diff --git a/packages/pieces/community/letmepost/src/index.ts b/packages/pieces/community/letmepost/src/index.ts new file mode 100644 index 000000000000..f44c59f56825 --- /dev/null +++ b/packages/pieces/community/letmepost/src/index.ts @@ -0,0 +1,35 @@ +import { createPiece } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { PieceCategory } from '@activepieces/shared'; +import { letmepostAuth } from './lib/common/auth'; +import { publishPost } from './lib/actions/publish-post'; +import { getPost } from './lib/actions/get-post'; +import { listAccounts } from './lib/actions/list-accounts'; +import { listMedia } from './lib/actions/list-media'; +import { newPublishedPost } from './lib/triggers/new-published-post'; +import { postEvent } from './lib/triggers/post-event'; + +export const letmepost = createPiece({ + displayName: 'Letmepost', + description: + 'Publish and schedule posts to Bluesky, X, LinkedIn, Instagram, Threads, Facebook, Pinterest, and TikTok through one API', + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/letmepost.png', + categories: [PieceCategory.MARKETING], + auth: letmepostAuth, + authors: ['rosekamallove', 'sanket-a11y'], + actions: [ + publishPost, + getPost, + listAccounts, + listMedia, + createCustomApiCallAction({ + baseUrl: (auth) => auth?.props.base_url ?? 'https://api.letmepost.dev', + auth: letmepostAuth, + authMapping: async (auth) => ({ + Authorization: `Bearer ${auth.props.api_key}`, + }), + }), + ], + triggers: [newPublishedPost, postEvent], +}); diff --git a/packages/pieces/community/letmepost/src/lib/actions/get-post.ts b/packages/pieces/community/letmepost/src/lib/actions/get-post.ts new file mode 100644 index 000000000000..405fde57e3c6 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/actions/get-post.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall } from '../common'; + +export const getPost = createAction({ + auth: letmepostAuth, + name: 'get_post', + displayName: 'Get a Post', + description: 'Retrieve a single post and its per-target results', + audience: 'both', + aiMetadata: { + description: + 'Fetches one post by id, including its status, scheduled and published times, the connected account it went to, and the resulting platform URL. Use to check whether a post published or to read back its details. Idempotent, a read-only lookup.', + idempotent: true, + }, + props: { + postId: Property.ShortText({ + displayName: 'Post ID', + description: 'The ID of the post to retrieve.', + required: true, + }), + }, + async run(context) { + const { postId } = context.propsValue; + + const response = await letmepostApiCall>({ + auth: context.auth, + method: HttpMethod.GET, + path: `/v1/posts/${encodeURIComponent(postId)}`, + }); + + return response.body; + }, +}); diff --git a/packages/pieces/community/letmepost/src/lib/actions/list-accounts.ts b/packages/pieces/community/letmepost/src/lib/actions/list-accounts.ts new file mode 100644 index 000000000000..65307f8ddd5a --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/actions/list-accounts.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall } from '../common'; + +export const listAccounts = createAction({ + auth: letmepostAuth, + name: 'list_accounts', + displayName: 'List Accounts', + description: 'List the social accounts connected to your organization', + audience: 'both', + aiMetadata: { + description: + 'Lists every connected account, returning each id, platform, and display name. Use to discover which accounts are available and to resolve the account id required by Publish a Post. Idempotent, a read-only lookup.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await letmepostApiCall<{ + data: { + id: string; + platform: string; + displayName: string | null; + profileId: string; + platformAccountId: string; + tokenExpiresAt: string | null; + createdAt: string; + }[]; + }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/v1/accounts', + }); + + return response.body.data; + }, +}); diff --git a/packages/pieces/community/letmepost/src/lib/actions/list-media.ts b/packages/pieces/community/letmepost/src/lib/actions/list-media.ts new file mode 100644 index 000000000000..df1c154c8369 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/actions/list-media.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall } from '../common'; + +export const listMedia = createAction({ + auth: letmepostAuth, + name: 'list_media', + displayName: 'List Media', + description: 'List previously uploaded media assets', + audience: 'both', + aiMetadata: { + description: + 'Lists uploaded media assets, returning each id, URL, content type, and size. Use to find an existing media id to reuse in Publish a Post instead of re-uploading. Idempotent, a read-only lookup.', + idempotent: true, + }, + props: { + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of media assets to return.', + required: false, + defaultValue: 50, + }), + }, + async run(context) { + const { limit } = context.propsValue; + + const response = await letmepostApiCall<{ + data: { + id: string; + url: string; + contentType: string; + sizeBytes: number; + createdAt: string; + }[]; + }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/v1/media', + queryParams: { limit: String(limit ?? 50) }, + }); + + return response.body.data; + }, +}); diff --git a/packages/pieces/community/letmepost/src/lib/actions/publish-post.ts b/packages/pieces/community/letmepost/src/lib/actions/publish-post.ts new file mode 100644 index 000000000000..ca1a8f948c15 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/actions/publish-post.ts @@ -0,0 +1,159 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall, letmepostCommon } from '../common'; + +export const publishPost = createAction({ + auth: letmepostAuth, + name: 'publish_post', + displayName: 'Publish a Post', + description: 'Publish or schedule a post to one or more connected accounts', + audience: 'both', + aiMetadata: { + description: + 'Sends one body of text and optional media to one or more connected accounts in a single call, either immediately or scheduled for a future time. Use to push content out through letmepost. A schedule time is required when Publish Now is off. Not idempotent unless an Idempotency Key is supplied, in which case replays within 24 hours return the original result.', + idempotent: false, + }, + props: { + accounts: letmepostCommon.accountsDropdown, + text: Property.LongText({ + displayName: 'Text', + description: + 'The post body. Optional when the post is media-only on platforms that allow it.', + required: false, + }), + media: Property.Array({ + displayName: 'Media', + description: + 'Media attachments. Give each item a public URL or an existing letmepost media ID, not both.', + required: false, + properties: { + kind: Property.StaticDropdown({ + displayName: 'Type', + required: true, + options: { + options: [ + { label: 'Image', value: 'image' }, + { label: 'Video', value: 'video' }, + ], + }, + }), + url: Property.ShortText({ + displayName: 'URL', + description: 'Public URL of the media. Use this or Media ID.', + required: false, + }), + mediaId: Property.ShortText({ + displayName: 'Media ID', + description: + 'ID of a media asset already uploaded to letmepost. Use this or URL.', + required: false, + }), + altText: Property.ShortText({ + displayName: 'Alt text', + description: 'Accessibility description for the media.', + required: false, + }), + }, + }), + firstComment: Property.LongText({ + displayName: 'First comment', + description: + 'Posted as the first comment under the published post, where the platform supports it.', + required: false, + }), + publishNow: Property.Checkbox({ + displayName: 'Publish now', + description: + 'Publish immediately. Turn off to schedule for a future time.', + required: false, + defaultValue: true, + }), + scheduledAt: Property.DateTime({ + displayName: 'Schedule at', + description: + 'When to publish the post (ISO 8601). Required when Publish Now is off.', + required: false, + }), + profileId: Property.ShortText({ + displayName: 'Profile ID', + description: 'Optional profile to attribute this post to.', + required: false, + }), + idempotencyKey: Property.ShortText({ + displayName: 'Idempotency key', + description: + 'Reusing the same key never publishes twice, which makes retries safe.', + required: false, + }), + }, + async run(context) { + const { + accounts, + text, + media, + firstComment, + publishNow, + scheduledAt, + profileId, + idempotencyKey, + } = context.propsValue; + + const publishImmediately = publishNow ?? true; + + if (!publishImmediately && !scheduledAt) { + throw new Error( + 'A Schedule at time is required when Publish now is turned off.' + ); + } + + const body: Record = { + targets: accounts.map((accountId) => ({ accountId })), + }; + if (publishImmediately) { + body['publishNow'] = true; + } else { + body['scheduledAt'] = scheduledAt; + } + + if (text) { + body['text'] = text; + } + if (media && media.length > 0) { + body['media'] = (media as Array>).map((item) => { + const cleaned: Record = { kind: item['kind'] }; + if (item['url']) { + cleaned['url'] = item['url']; + } + if (item['mediaId']) { + cleaned['mediaId'] = item['mediaId']; + } + if (item['altText']) { + cleaned['altText'] = item['altText']; + } + return cleaned; + }); + } + if (firstComment) { + body['firstComment'] = { text: firstComment }; + } + if (!publishImmediately && scheduledAt) { + body['scheduledAt'] = scheduledAt; + } + if (profileId) { + body['profileId'] = profileId; + } + + const response = await letmepostApiCall>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/v1/posts', + body, + headers: idempotencyKey + ? { 'Idempotency-Key': idempotencyKey } + : undefined, + }); + + return response.body; + }, +}); diff --git a/packages/pieces/community/letmepost/src/lib/common/auth.ts b/packages/pieces/community/letmepost/src/lib/common/auth.ts new file mode 100644 index 000000000000..813fe6d6dc52 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/common/auth.ts @@ -0,0 +1,46 @@ +import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; + +export const letmepostAuth = PieceAuth.CustomAuth({ + displayName: 'letmepost Connection', + required: true, + props: { + base_url: Property.ShortText({ + displayName: 'Base URL', + description: + 'The API base URL. Use `https://api.letmepost.dev` for the hosted service, or your own origin when self-hosting.', + required: true, + defaultValue: 'https://api.letmepost.dev', + }), + api_key: PieceAuth.SecretText({ + displayName: 'API Key', + description: `Your letmepost API key (\`lmp_live_…\` or \`lmp_test_…\`). Create one in the dashboard under API keys.`, + required: true, + }), + }, + validate: async ({ auth }) => { + try { + const baseUrl = auth.base_url?.trim().replace(/\/+$/, ''); + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${baseUrl}/v1/accounts`, + headers: { + Authorization: `Bearer ${auth.api_key}`, + }, + }); + return { valid: true }; + } catch { + return { + valid: false, + error: 'Invalid API key or base URL. Please check your credentials.', + }; + } + }, +}); + +export type LetmepostAuth = { + props: { + base_url: string; + api_key: string; + }; +}; diff --git a/packages/pieces/community/letmepost/src/lib/common/index.ts b/packages/pieces/community/letmepost/src/lib/common/index.ts new file mode 100644 index 000000000000..ce891d180ad0 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/common/index.ts @@ -0,0 +1,88 @@ +import { + httpClient, + HttpMethod, + HttpMessageBody, + HttpResponse, +} from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import { letmepostAuth, LetmepostAuth } from './auth'; + +const DEFAULT_BASE_URL = 'https://api.letmepost.dev'; + +function buildBaseUrl(auth: LetmepostAuth): string { + const url = auth.props.base_url?.trim(); + if (!url) { + return DEFAULT_BASE_URL; + } + return url.replace(/\/+$/, ''); +} + +export async function letmepostApiCall({ + auth, + method, + path, + body, + queryParams, + headers, +}: { + auth: LetmepostAuth; + method: HttpMethod; + path: string; + body?: unknown; + queryParams?: Record; + headers?: Record; +}): Promise> { + return await httpClient.sendRequest({ + method, + url: `${buildBaseUrl(auth)}${path}`, + headers: { + Authorization: `Bearer ${auth.props.api_key}`, + ...headers, + }, + queryParams, + body, + }); +} + +type AccountListResponse = { + data: { + id: string; + platform: string; + displayName: string | null; + }[]; +}; + +export const letmepostCommon = { + accountsDropdown: Property.MultiSelectDropdown({ + displayName: 'Accounts', + description: 'The connected accounts to publish to', + refreshers: ['auth'], + required: true, + auth: letmepostAuth, + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Connect your letmepost account first', + }; + } + const response = await letmepostApiCall({ + auth: auth as LetmepostAuth, + method: HttpMethod.GET, + path: '/v1/accounts', + }); + return { + disabled: false, + options: response.body.data.map((account) => ({ + label: `${account.displayName ?? account.platform} (${ + account.platform + })`, + value: account.id, + })), + }; + }, + }), +}; + +export type { LetmepostAuth } from './auth'; diff --git a/packages/pieces/community/letmepost/src/lib/triggers/new-published-post.ts b/packages/pieces/community/letmepost/src/lib/triggers/new-published-post.ts new file mode 100644 index 000000000000..51e00c520098 --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/triggers/new-published-post.ts @@ -0,0 +1,90 @@ +import { + createTrigger, + TriggerStrategy, + AppConnectionValueForAuthProperty, +} from '@activepieces/pieces-framework'; +import { + DedupeStrategy, + Polling, + pollingHelper, + HttpMethod, +} from '@activepieces/pieces-common'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall } from '../common'; + +const polling: Polling< + AppConnectionValueForAuthProperty, + Record +> = { + strategy: DedupeStrategy.TIMEBASED, + items: async ({ auth }) => { + const response = await letmepostApiCall<{ + data: { + id: string; + accountId: string; + platform: string; + status: string; + text: string; + publishedAt: string | null; + platformUri: string | null; + createdAt: string; + }[]; + }>({ + auth: auth, + method: HttpMethod.GET, + path: '/v1/posts', + queryParams: { limit: '100' }, + }); + + return response.body.data + .filter((post) => post.status === 'published') + .map((post) => ({ + epochMilliSeconds: new Date( + post.publishedAt ?? post.createdAt, + ).getTime(), + data: { + id: post.id, + account_id: post.accountId, + platform: post.platform, + status: post.status, + text: post.text, + published_at: post.publishedAt, + platform_url: post.platformUri, + }, + })); + }, +}; + +export const newPublishedPost = createTrigger({ + auth: letmepostAuth, + name: 'new_published_post', + displayName: 'New Published Post', + description: 'Triggers when a post is published', + aiMetadata: { + description: + 'Fires when a post reaches the published state, emitting the post id, account, platform, text, publish time, and the resulting platform URL. Use to react to content going live, for example to log it or notify a channel. Polls recent posts and emits each newly published one.', + }, + props: {}, + sampleData: { + id: 'post_01HXY7Z8K9MNB1P2QR3STVW', + account_id: 'acc_01HXY7Z8K9MNB1P2QR3STVW', + platform: 'bluesky', + status: 'published', + text: 'Shipped multi-target publishing today.', + published_at: '2026-06-18T10:00:00.000Z', + platform_url: 'https://bsky.app/profile/letmepost.dev/post/abc123', + }, + type: TriggerStrategy.POLLING, + async test(context) { + return await pollingHelper.test(polling, context); + }, + async onEnable(context) { + await pollingHelper.onEnable(polling, context); + }, + async onDisable(context) { + await pollingHelper.onDisable(polling, context); + }, + async run(context) { + return await pollingHelper.poll(polling, context); + }, +}); diff --git a/packages/pieces/community/letmepost/src/lib/triggers/post-event.ts b/packages/pieces/community/letmepost/src/lib/triggers/post-event.ts new file mode 100644 index 000000000000..f926f88b41da --- /dev/null +++ b/packages/pieces/community/letmepost/src/lib/triggers/post-event.ts @@ -0,0 +1,147 @@ +import { + createTrigger, + TriggerStrategy, + Property, +} from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { letmepostAuth } from '../common/auth'; +import { letmepostApiCall } from '../common'; + +const ENDPOINT_STORE_KEY = 'letmepost_webhook_endpoint_id'; +const SECRET_STORE_KEY = 'letmepost_webhook_signing_secret'; + +function isValidSignature( + secret: string, + rawBody: string, + signature: string +): boolean { + const presented = signature.startsWith('sha256=') + ? signature.slice('sha256='.length) + : signature; + const expected = createHmac('sha256', secret) + .update(rawBody, 'utf8') + .digest('hex'); + if (presented.length !== expected.length) { + return false; + } + try { + return timingSafeEqual( + Buffer.from(presented, 'hex'), + Buffer.from(expected, 'hex') + ); + } catch { + return false; + } +} + +export const postEvent = createTrigger({ + auth: letmepostAuth, + name: 'post_event', + displayName: 'Post Event', + description: + 'Triggers instantly when a post changes state (published, failed, rejected, and more)', + aiMetadata: { + description: + 'Fires the moment a post changes state, delivered over a registered webhook rather than polling. Emits the event envelope: id, type (e.g. post.published, post.failed), createdAt, and the event-specific data. Subscribe to specific lifecycle events or leave the selection empty to receive all of them.', + }, + type: TriggerStrategy.WEBHOOK, + props: { + events: Property.StaticMultiSelectDropdown({ + displayName: 'Events', + description: + 'Which events to listen for. Leave empty to receive all events.', + required: false, + options: { + options: [ + { label: 'Post queued', value: 'post.queued' }, + { label: 'Post validated', value: 'post.validated' }, + { label: 'Post published', value: 'post.published' }, + { label: 'Post rejected', value: 'post.rejected' }, + { label: 'Post failed', value: 'post.failed' }, + { label: 'Post canceled', value: 'post.canceled' }, + { label: 'Post rescheduled', value: 'post.rescheduled' }, + { label: 'Token expiring', value: 'token.expiring' }, + { label: 'Token revoked', value: 'token.revoked' }, + { label: 'Version deprecated', value: 'version.deprecated' }, + ], + }, + }), + }, + sampleData: { + id: '3bb21953-33c4-47f9-9401-7323e84ee6e0', + type: 'post.published', + createdAt: '2026-06-19T10:29:47.790Z', + organizationId: '019edf51-a4fd-7e45-95f2-d104b470d962', + data: { + postId: '00000000-0000-0000-0000-000000000000', + platform: 'bluesky', + status: 'published', + text: 'Test webhook from letmepost.dev', + publishedAt: '2026-06-19T09:58:52.423Z', + platformUri: 'at://did:plc:test/app.bsky.feed.post/test', + }, + }, + async onEnable(context) { + const response = await letmepostApiCall<{ + id: string; + signingSecret: string; + }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/v1/webhook-endpoints', + body: { + url: context.webhookUrl, + events: context.propsValue.events ?? [], + description: 'Activepieces', + }, + }); + + try { + await context.store.put(ENDPOINT_STORE_KEY, response.body.id); + await context.store.put(SECRET_STORE_KEY, response.body.signingSecret); + } catch (error) { + await letmepostApiCall>({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/v1/webhook-endpoints/${response.body.id}`, + }); + throw error; + } + }, + async onDisable(context) { + const endpointId = await context.store.get(ENDPOINT_STORE_KEY); + if (endpointId) { + await letmepostApiCall>({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/v1/webhook-endpoints/${endpointId}`, + }); + } + }, + async run(context) { + const body = context.payload.body as any; + const secret = await context.store.get(SECRET_STORE_KEY); + const headers = context.payload.headers ?? {}; + const signature = + headers['x-letmepost-signature'] ?? headers['X-Letmepost-Signature']; + const rawBody = context.payload.rawBody; + + if ( + !secret || + typeof rawBody !== 'string' || + typeof signature !== 'string' || + !isValidSignature(secret, rawBody, signature) + ) { + return []; + } + const events = context.propsValue.events; + if (events && events.length > 0) { + const eventType = body?.type; + if (!eventType || !events.includes(eventType)) { + return []; + } + } + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/letmepost/tsconfig.json b/packages/pieces/community/letmepost/tsconfig.json new file mode 100644 index 000000000000..059cd8166183 --- /dev/null +++ b/packages/pieces/community/letmepost/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/pieces/community/letmepost/tsconfig.lib.json b/packages/pieces/community/letmepost/tsconfig.lib.json new file mode 100644 index 000000000000..0ba4caeb858b --- /dev/null +++ b/packages/pieces/community/letmepost/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +}