From 23899c9eae928c1e4752ee561a8e978d19051f86 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Fri, 25 Sep 2026 13:38:36 +0300 Subject: [PATCH 1/8] feat(pieces): add AI-agent atomics to mastodon and output schemas to crypto (#15756) --- bun.lock | 4 +- .../pieces/community/mastodon/package.json | 2 +- .../mastodon/src/i18n/translation.json | 413 ++++++++++- .../pieces/community/mastodon/src/index.ts | 177 ++++- .../src/lib/actions/accept-follow-request.ts | 37 + .../src/lib/actions/add-accounts-to-list.ts | 49 ++ .../mastodon/src/lib/actions/block-account.ts | 37 + .../src/lib/actions/bookmark-status.ts | 37 + .../mastodon/src/lib/actions/boost-status.ts | 53 ++ .../lib/actions/cancel-scheduled-status.ts | 38 + .../src/lib/actions/clear-notifications.ts | 32 + .../mastodon/src/lib/actions/create-filter.ts | 103 +++ .../mastodon/src/lib/actions/create-list.ts | 63 ++ .../mastodon/src/lib/actions/create-status.ts | 61 ++ .../src/lib/actions/delete-conversation.ts | 38 + .../mastodon/src/lib/actions/delete-filter.ts | 39 ++ .../mastodon/src/lib/actions/delete-list.ts | 38 + .../mastodon/src/lib/actions/delete-media.ts | 41 ++ .../mastodon/src/lib/actions/delete-status.ts | 45 ++ .../src/lib/actions/dismiss-notification.ts | 38 + .../mastodon/src/lib/actions/edit-status.ts | 227 ++++++ .../src/lib/actions/favourite-status.ts | 37 + .../src/lib/actions/follow-account.ts | 63 ++ .../mastodon/src/lib/actions/follow-tag.ts | 38 + .../mastodon/src/lib/actions/get-account.ts | 37 + .../mastodon/src/lib/actions/get-filter.ts | 38 + .../src/lib/actions/get-hashtag-timeline.ts | 94 +++ .../src/lib/actions/get-home-timeline.ts | 38 + .../src/lib/actions/get-instance-info.ts | 31 + .../src/lib/actions/get-list-timeline.ts | 44 ++ .../mastodon/src/lib/actions/get-list.ts | 37 + .../mastodon/src/lib/actions/get-media.ts | 55 ++ .../src/lib/actions/get-my-account.ts | 31 + .../src/lib/actions/get-notification.ts | 37 + .../mastodon/src/lib/actions/get-poll.ts | 37 + .../src/lib/actions/get-public-timeline.ts | 63 ++ .../src/lib/actions/get-relationships.ts | 43 ++ .../src/lib/actions/get-scheduled-status.ts | 37 + .../src/lib/actions/get-status-context.ts | 37 + .../mastodon/src/lib/actions/get-status.ts | 37 + .../mastodon/src/lib/actions/get-tag.ts | 37 + .../src/lib/actions/get-trending-links.ts | 39 ++ .../src/lib/actions/get-trending-statuses.ts | 39 ++ .../src/lib/actions/get-trending-tags.ts | 39 ++ .../actions/get-unread-notification-count.ts | 64 ++ .../src/lib/actions/list-account-followers.ts | 44 ++ .../src/lib/actions/list-account-following.ts | 44 ++ .../src/lib/actions/list-account-statuses.ts | 79 +++ .../src/lib/actions/list-blocked-accounts.ts | 38 + .../src/lib/actions/list-bookmarks.ts | 38 + .../src/lib/actions/list-conversations.ts | 38 + .../src/lib/actions/list-favourites.ts | 38 + .../mastodon/src/lib/actions/list-filters.ts | 32 + .../src/lib/actions/list-follow-requests.ts | 37 + .../src/lib/actions/list-followed-tags.ts | 39 ++ .../src/lib/actions/list-list-accounts.ts | 44 ++ .../mastodon/src/lib/actions/list-lists.ts | 31 + .../src/lib/actions/list-muted-accounts.ts | 37 + .../src/lib/actions/list-notifications.ts | 64 ++ .../lib/actions/list-scheduled-statuses.ts | 38 + .../src/lib/actions/list-status-boosted-by.ts | 42 ++ .../lib/actions/list-status-favourited-by.ts | 42 ++ .../src/lib/actions/lookup-account.ts | 40 ++ .../src/lib/actions/mark-conversation-read.ts | 37 + .../mastodon/src/lib/actions/mute-account.ts | 51 ++ .../src/lib/actions/mute-conversation.ts | 37 + .../mastodon/src/lib/actions/pin-status.ts | 37 + .../mastodon/src/lib/actions/post-status.ts | 4 +- .../src/lib/actions/reject-follow-request.ts | 37 + .../lib/actions/remove-accounts-from-list.ts | 49 ++ .../src/lib/actions/reschedule-status.ts | 45 ++ .../src/lib/actions/schedule-status.ts | 68 ++ .../src/lib/actions/search-accounts.ts | 53 ++ .../mastodon/src/lib/actions/search.ts | 105 +++ .../src/lib/actions/unblock-account.ts | 37 + .../src/lib/actions/unbookmark-status.ts | 37 + .../src/lib/actions/unboost-status.ts | 37 + .../src/lib/actions/unfavourite-status.ts | 37 + .../src/lib/actions/unfollow-account.ts | 37 + .../mastodon/src/lib/actions/unfollow-tag.ts | 38 + .../src/lib/actions/unmute-account.ts | 37 + .../src/lib/actions/unmute-conversation.ts | 37 + .../mastodon/src/lib/actions/unpin-status.ts | 37 + .../mastodon/src/lib/actions/update-filter.ts | 88 +++ .../mastodon/src/lib/actions/update-list.ts | 72 ++ .../mastodon/src/lib/actions/update-media.ts | 58 ++ .../src/lib/actions/update-my-profile.ts | 97 +++ .../mastodon/src/lib/actions/upload-media.ts | 75 ++ .../mastodon/src/lib/actions/vote-in-poll.ts | 50 ++ .../mastodon/src/lib/common/client.ts | 534 +++++++++++++++ .../mastodon/src/lib/output-schemas.ts | 647 ++++++++++++++++++ packages/pieces/core/crypto/package.json | 2 +- .../crypto/src/lib/actions/base64-decode.ts | 2 + .../crypto/src/lib/actions/base64-encode.ts | 2 + .../src/lib/actions/generate-password.ts | 2 + .../core/crypto/src/lib/actions/hash-text.ts | 2 + .../crypto/src/lib/actions/hmac-signature.ts | 2 + .../crypto/src/lib/actions/rsa-signature.ts | 2 + .../core/crypto/src/lib/output-schemas.ts | 51 ++ 99 files changed, 5922 insertions(+), 10 deletions(-) create mode 100644 packages/pieces/community/mastodon/src/lib/actions/accept-follow-request.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/add-accounts-to-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/block-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/bookmark-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/boost-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/cancel-scheduled-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/clear-notifications.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/create-filter.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/create-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/create-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/delete-conversation.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/delete-filter.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/delete-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/delete-media.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/delete-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/dismiss-notification.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/edit-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/favourite-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/follow-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/follow-tag.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-filter.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-hashtag-timeline.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-home-timeline.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-instance-info.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-list-timeline.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-media.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-my-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-notification.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-poll.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-public-timeline.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-relationships.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-scheduled-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-status-context.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-tag.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-trending-links.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-trending-statuses.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-trending-tags.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/get-unread-notification-count.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-account-followers.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-account-following.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-account-statuses.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-blocked-accounts.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-bookmarks.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-conversations.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-favourites.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-filters.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-follow-requests.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-followed-tags.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-list-accounts.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-lists.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-muted-accounts.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-notifications.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-scheduled-statuses.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-status-boosted-by.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/list-status-favourited-by.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/lookup-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/mark-conversation-read.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/mute-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/mute-conversation.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/pin-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/reject-follow-request.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/remove-accounts-from-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/reschedule-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/schedule-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/search-accounts.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/search.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unblock-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unbookmark-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unboost-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unfavourite-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unfollow-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unfollow-tag.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unmute-account.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unmute-conversation.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/unpin-status.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/update-filter.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/update-list.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/update-media.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/update-my-profile.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/upload-media.ts create mode 100644 packages/pieces/community/mastodon/src/lib/actions/vote-in-poll.ts create mode 100644 packages/pieces/community/mastodon/src/lib/common/client.ts create mode 100644 packages/pieces/community/mastodon/src/lib/output-schemas.ts diff --git a/bun.lock b/bun.lock index c4e9e98326d4..1486ea18d913 100644 --- a/bun.lock +++ b/bun.lock @@ -5493,7 +5493,7 @@ }, "packages/pieces/community/mastodon": { "name": "@activepieces/piece-mastodon", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10301,7 +10301,7 @@ }, "packages/pieces/core/crypto": { "name": "@activepieces/piece-crypto", - "version": "0.0.28", + "version": "0.0.29", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/mastodon/package.json b/packages/pieces/community/mastodon/package.json index 5ba020be9eca..80adc540dc93 100644 --- a/packages/pieces/community/mastodon/package.json +++ b/packages/pieces/community/mastodon/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-mastodon", - "version": "0.6.0", + "version": "0.7.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/mastodon/src/i18n/translation.json b/packages/pieces/community/mastodon/src/i18n/translation.json index a27d1b8a092f..632a419f437c 100644 --- a/packages/pieces/community/mastodon/src/i18n/translation.json +++ b/packages/pieces/community/mastodon/src/i18n/translation.json @@ -4,7 +4,6 @@ "Access Token": "Access Token", "The base URL of your Mastodon instance": "The base URL of your Mastodon instance", "The access token for your Mastodon account, check the documentation for how to get this": "The access token for your Mastodon account, check the documentation for how to get this", - "\n**Base Url**: The base url of your Mastodon instance (e.g `https://mastodon.social`)\n\n**Access Token**: To get your access token, follow the steps below:\n\n1. Go to your **Profile** -> **Preferences** -> **Development** -> **New Application**\n2. Fill the Information\n3. Click on **Create Application**\n4. Copy access token from **Your access token**\n": "\n**Base Url**: The base url of your Mastodon instance (e.g `https://mastodon.social`)\n\n**Access Token**: To get your access token, follow the steps below:\n\n1. Go to your **Profile** -> **Preferences** -> **Development** -> **New Application**\n2. Fill the Information\n3. Click on **Create Application**\n4. Copy access token from **Your access token**\n", "Post Status": "Post Status", "Custom API Call": "Custom API Call", "Post a status to Mastodon": "Post a status to Mastodon", @@ -33,5 +32,415 @@ "None": "None", "JSON": "JSON", "Form Data": "Form Data", - "Raw": "Raw" + "Raw": "Raw", + "\n**Base Url**: The base url of your Mastodon instance (e.g `https://mastodon.social`)\n\n**Access Token**: To get your access token, follow the steps below:\n\n1. Go to your **Profile** -> **Preferences** -> **Development** -> **New Application**\n2. Fill the Information\n3. Under **Scopes**, tick **read** and **write** (the form pre-selects only `profile`)\n4. Click on **Create Application**\n5. Copy access token from **Your access token**\n\nIf an existing connection fails with a missing-scope error, edit the appli": "\n**Base Url**: The base url of your Mastodon instance (e.g `https://mastodon.social`)\n\n**Access Token**: To get your access token, follow the steps below:\n\n1. Go to your **Profile** -> **Preferences** -> **Development** -> **New Application**\n2. Fill the Information\n3. Under **Scopes**, tick **read** and **write** (the form pre-selects only `profile`)\n4. Click on **Create Application**\n5. Copy access token from **Your access token**\n\nIf an existing connection fails with a missing-scope error, edit the application in Mastodon, tick **read** and **write**, save (this regenerates the token) and paste the new token here.\n", + "Create Status": "Create Status", + "Schedule Status": "Schedule Status", + "Get Status": "Get Status", + "Edit Status": "Edit Status", + "Delete Status": "Delete Status", + "Get Status Thread": "Get Status Thread", + "List Accounts That Boosted a Status": "List Accounts That Boosted a Status", + "List Accounts That Favourited a Status": "List Accounts That Favourited a Status", + "Favourite Status": "Favourite Status", + "Unfavourite Status": "Unfavourite Status", + "Boost Status": "Boost Status", + "Unboost Status": "Unboost Status", + "Bookmark Status": "Bookmark Status", + "Remove Bookmark": "Remove Bookmark", + "Mute Conversation": "Mute Conversation", + "Unmute Conversation": "Unmute Conversation", + "Pin Status to Profile": "Pin Status to Profile", + "Unpin Status from Profile": "Unpin Status from Profile", + "List Scheduled Statuses": "List Scheduled Statuses", + "Get Scheduled Status": "Get Scheduled Status", + "Reschedule Status": "Reschedule Status", + "Cancel Scheduled Status": "Cancel Scheduled Status", + "Upload Media": "Upload Media", + "Get Media": "Get Media", + "Update Media": "Update Media", + "Delete Media": "Delete Media", + "Get My Account": "Get My Account", + "Update My Profile": "Update My Profile", + "Get Account": "Get Account", + "Lookup Account": "Lookup Account", + "Search Accounts": "Search Accounts", + "List Account Statuses": "List Account Statuses", + "List Account Followers": "List Account Followers", + "List Account Following": "List Account Following", + "Get Relationships": "Get Relationships", + "Follow Account": "Follow Account", + "Unfollow Account": "Unfollow Account", + "Block Account": "Block Account", + "Unblock Account": "Unblock Account", + "Mute Account": "Mute Account", + "Unmute Account": "Unmute Account", + "List Blocked Accounts": "List Blocked Accounts", + "List Muted Accounts": "List Muted Accounts", + "List Follow Requests": "List Follow Requests", + "Accept Follow Request": "Accept Follow Request", + "Reject Follow Request": "Reject Follow Request", + "List Bookmarks": "List Bookmarks", + "List Favourites": "List Favourites", + "Get Home Timeline": "Get Home Timeline", + "Get Public Timeline": "Get Public Timeline", + "Get Hashtag Timeline": "Get Hashtag Timeline", + "Get List Timeline": "Get List Timeline", + "List Notifications": "List Notifications", + "Get Notification": "Get Notification", + "Dismiss Notification": "Dismiss Notification", + "Clear All Notifications": "Clear All Notifications", + "Get Unread Notification Count": "Get Unread Notification Count", + "Search": "Search", + "List Lists": "List Lists", + "Get List": "Get List", + "Create List": "Create List", + "Update List": "Update List", + "Delete List": "Delete List", + "List Accounts in List": "List Accounts in List", + "Add Accounts to List": "Add Accounts to List", + "Remove Accounts from List": "Remove Accounts from List", + "Get Poll": "Get Poll", + "Vote in Poll": "Vote in Poll", + "List Conversations": "List Conversations", + "Mark Conversation as Read": "Mark Conversation as Read", + "Remove Conversation": "Remove Conversation", + "Get Hashtag": "Get Hashtag", + "Follow Hashtag": "Follow Hashtag", + "Unfollow Hashtag": "Unfollow Hashtag", + "List Followed Hashtags": "List Followed Hashtags", + "List Filters": "List Filters", + "Get Filter": "Get Filter", + "Create Filter": "Create Filter", + "Update Filter": "Update Filter", + "Delete Filter": "Delete Filter", + "Get Trending Hashtags": "Get Trending Hashtags", + "Get Trending Statuses": "Get Trending Statuses", + "Get Trending Links": "Get Trending Links", + "Get Instance Info": "Get Instance Info", + "Publish a status (toot), reply, or poll from the connected account.": "Publish a status (toot), reply, or poll from the connected account.", + "Schedule a status to be published automatically at a future time.": "Schedule a status to be published automatically at a future time.", + "Get a single status by its ID.": "Get a single status by its ID.", + "Edit the text, content warning, media or poll of one of your statuses.": "Edit the text, content warning, media or poll of one of your statuses.", + "Permanently delete one of your own statuses.": "Permanently delete one of your own statuses.", + "Get the ancestors and replies of a status.": "Get the ancestors and replies of a status.", + "List the accounts that boosted (reblogged) a status.": "List the accounts that boosted (reblogged) a status.", + "List the accounts that favourited a status.": "List the accounts that favourited a status.", + "Add a status to your favourites.": "Add a status to your favourites.", + "Remove a status from your favourites.": "Remove a status from your favourites.", + "Boost (reblog) a status to your followers.": "Boost (reblog) a status to your followers.", + "Undo a boost (reblog) of a status.": "Undo a boost (reblog) of a status.", + "Privately bookmark a status.": "Privately bookmark a status.", + "Remove a status from your bookmarks.": "Remove a status from your bookmarks.", + "Stop receiving notifications for a thread.": "Stop receiving notifications for a thread.", + "Resume notifications for a muted thread.": "Resume notifications for a muted thread.", + "Feature one of your statuses at the top of your profile.": "Feature one of your statuses at the top of your profile.", + "Remove a pinned status from your profile.": "Remove a pinned status from your profile.", + "List your statuses scheduled for future publication.": "List your statuses scheduled for future publication.", + "Get a scheduled status by its ID.": "Get a scheduled status by its ID.", + "Change the publication time of a scheduled status.": "Change the publication time of a scheduled status.", + "Cancel a scheduled status so it is never published.": "Cancel a scheduled status so it is never published.", + "Upload an image, video or audio file to attach to a status.": "Upload an image, video or audio file to attach to a status.", + "Check an uploaded media file, including whether it has finished processing.": "Check an uploaded media file, including whether it has finished processing.", + "Change the alt text or focal point of an uploaded media file.": "Change the alt text or focal point of an uploaded media file.", + "Delete an uploaded media file that is not attached to a status.": "Delete an uploaded media file that is not attached to a status.", + "Get the profile of the connected account.": "Get the profile of the connected account.", + "Update the display name, bio and settings of the connected account.": "Update the display name, bio and settings of the connected account.", + "Get a Mastodon account profile by its ID.": "Get a Mastodon account profile by its ID.", + "Find an account by its handle (username or user@domain).": "Find an account by its handle (username or user@domain).", + "Search for accounts by name or handle.": "Search for accounts by name or handle.", + "List the statuses posted by an account.": "List the statuses posted by an account.", + "List the followers of an account.": "List the followers of an account.", + "List the accounts an account follows.": "List the accounts an account follows.", + "Check how the connected account relates to other accounts.": "Check how the connected account relates to other accounts.", + "Follow an account, or send a follow request to a locked account.": "Follow an account, or send a follow request to a locked account.", + "Stop following an account.": "Stop following an account.", + "Block an account.": "Block an account.", + "Unblock an account.": "Unblock an account.", + "Hide an account's posts, and optionally its notifications.": "Hide an account's posts, and optionally its notifications.", + "Unmute an account.": "Unmute an account.", + "List the accounts you have blocked.": "List the accounts you have blocked.", + "List the accounts you have muted.": "List the accounts you have muted.", + "List pending requests to follow your account.": "List pending requests to follow your account.", + "Accept a pending follow request.": "Accept a pending follow request.", + "Reject a pending follow request.": "Reject a pending follow request.", + "List the statuses you have bookmarked.": "List the statuses you have bookmarked.", + "List the statuses you have favourited.": "List the statuses you have favourited.", + "Get statuses from accounts and hashtags you follow.": "Get statuses from accounts and hashtags you follow.", + "Get recent public statuses known to the server.": "Get recent public statuses known to the server.", + "Get recent public statuses that use a hashtag.": "Get recent public statuses that use a hashtag.", + "Get statuses from the members of one of your lists.": "Get statuses from the members of one of your lists.", + "List notifications of the connected account.": "List notifications of the connected account.", + "Get a single notification by its ID.": "Get a single notification by its ID.", + "Dismiss a single notification.": "Dismiss a single notification.", + "Remove all notifications of the connected account.": "Remove all notifications of the connected account.", + "Get the number of unread notifications.": "Get the number of unread notifications.", + "Search for accounts, statuses and hashtags.": "Search for accounts, statuses and hashtags.", + "List all of your lists.": "List all of your lists.", + "Get one of your lists by its ID.": "Get one of your lists by its ID.", + "Create a new list for grouping accounts you follow.": "Create a new list for grouping accounts you follow.", + "Rename a list or change its settings.": "Rename a list or change its settings.", + "Delete one of your lists.": "Delete one of your lists.", + "List the accounts that are members of one of your lists.": "List the accounts that are members of one of your lists.", + "Add accounts you follow to one of your lists.": "Add accounts you follow to one of your lists.", + "Remove accounts from one of your lists.": "Remove accounts from one of your lists.", + "Get a poll with its current results.": "Get a poll with its current results.", + "Cast a vote in a poll.": "Cast a vote in a poll.", + "List your direct-message conversations.": "List your direct-message conversations.", + "Mark a direct-message conversation as read.": "Mark a direct-message conversation as read.", + "Remove a direct-message conversation from your list.": "Remove a direct-message conversation from your list.", + "Get information about a hashtag.": "Get information about a hashtag.", + "Follow a hashtag so its posts appear in your home timeline.": "Follow a hashtag so its posts appear in your home timeline.", + "Stop following a hashtag.": "Stop following a hashtag.", + "List the hashtags you follow.": "List the hashtags you follow.", + "List all of your content filters.": "List all of your content filters.", + "Get one of your content filters by its ID.": "Get one of your content filters by its ID.", + "Create a content filter that hides or warns about matching posts.": "Create a content filter that hides or warns about matching posts.", + "Change the title, contexts, action or expiry of a content filter.": "Change the title, contexts, action or expiry of a content filter.", + "Delete one of your content filters.": "Delete one of your content filters.", + "Get hashtags that are trending on the server.": "Get hashtags that are trending on the server.", + "Get statuses that are trending on the server.": "Get statuses that are trending on the server.", + "Get news links that are trending on the server.": "Get news links that are trending on the server.", + "Get information about the connected Mastodon server.": "Get information about the connected Mastodon server.", + "Status Text": "Status Text", + "Visibility": "Visibility", + "In Reply To (Status ID)": "In Reply To (Status ID)", + "Content Warning": "Content Warning", + "Mark Media as Sensitive": "Mark Media as Sensitive", + "Language": "Language", + "Media IDs": "Media IDs", + "Poll Options": "Poll Options", + "Poll Duration (seconds)": "Poll Duration (seconds)", + "Allow Multiple Choices": "Allow Multiple Choices", + "Idempotency Key": "Idempotency Key", + "Scheduled At": "Scheduled At", + "Status ID": "Status ID", + "Delete Media Immediately": "Delete Media Immediately", + "Limit": "Limit", + "Max ID (older page)": "Max ID (older page)", + "Since ID (newer results)": "Since ID (newer results)", + "Min ID (newer page)": "Min ID (newer page)", + "Scheduled Status ID": "Scheduled Status ID", + "File": "File", + "Alt Text": "Alt Text", + "Focal Point": "Focal Point", + "Media ID": "Media ID", + "Display Name": "Display Name", + "Bio": "Bio", + "Require Follow Approval": "Require Follow Approval", + "Bot Account": "Bot Account", + "Discoverable": "Discoverable", + "Default Post Visibility": "Default Post Visibility", + "Default Post Language": "Default Post Language", + "Account ID": "Account ID", + "Handle": "Handle", + "Query": "Query", + "Offset": "Offset", + "Resolve Remote Accounts": "Resolve Remote Accounts", + "Only Accounts I Follow": "Only Accounts I Follow", + "Only Statuses With Media": "Only Statuses With Media", + "Exclude Replies": "Exclude Replies", + "Exclude Boosts": "Exclude Boosts", + "Only Pinned Statuses": "Only Pinned Statuses", + "Hashtag Filter": "Hashtag Filter", + "Account IDs": "Account IDs", + "Show Their Boosts": "Show Their Boosts", + "Notify When They Post": "Notify When They Post", + "Languages": "Languages", + "Mute Notifications": "Mute Notifications", + "Duration (seconds)": "Duration (seconds)", + "Only Local Statuses": "Only Local Statuses", + "Only Remote Statuses": "Only Remote Statuses", + "Hashtag": "Hashtag", + "Any of These Tags": "Any of These Tags", + "All of These Tags": "All of These Tags", + "None of These Tags": "None of These Tags", + "List ID": "List ID", + "Include Types": "Include Types", + "Exclude Types": "Exclude Types", + "From Account ID": "From Account ID", + "Notification ID": "Notification ID", + "Count Only Types": "Count Only Types", + "Maximum Count": "Maximum Count", + "Result Type": "Result Type", + "Resolve Remote Content": "Resolve Remote Content", + "Statuses By Account ID": "Statuses By Account ID", + "Exclude Unreviewed Hashtags": "Exclude Unreviewed Hashtags", + "Max ID": "Max ID", + "Min ID": "Min ID", + "Title": "Title", + "Show Replies To": "Show Replies To", + "Hide Members From Home Timeline": "Hide Members From Home Timeline", + "Poll ID": "Poll ID", + "Choices": "Choices", + "Conversation ID": "Conversation ID", + "Filter ID": "Filter ID", + "Apply In": "Apply In", + "Keywords": "Keywords", + "Match Whole Words Only": "Match Whole Words Only", + "Action": "Action", + "Expires In (seconds)": "Expires In (seconds)", + "The text of the post. Mentions (@user@domain), hashtags and links are parsed by Mastodon. Required unless Media IDs are provided. Use Get Instance Info to read the server character limit (500 by default).": "The text of the post. Mentions (@user@domain), hashtags and links are parsed by Mastodon. Required unless Media IDs are provided. Use Get Instance Info to read the server character limit (500 by default).", + "Who can see the post. Leave empty to use the account default posting privacy.": "Who can see the post. Leave empty to use the account default posting privacy.", + "Local ID of the status to reply to, for example 109372843234737004. Obtain it from Get Status, a timeline or Search (with resolve for remote URLs).": "Local ID of the status to reply to, for example 109372843234737004. Obtain it from Get Status, a timeline or Search (with resolve for remote URLs).", + "Optional content warning shown before the post text.": "Optional content warning shown before the post text.", + "Whether attached media should be hidden behind a sensitive-content warning. Leave empty for the account default.": "Whether attached media should be hidden behind a sensitive-content warning. Leave empty for the account default.", + "ISO 639-1 two-letter language code of the post, for example en or de.": "ISO 639-1 two-letter language code of the post, for example en or de.", + "IDs of media returned by Upload Media, one per item (up to the server limit, 4 by default). Cannot be combined with a poll.": "IDs of media returned by Upload Media, one per item (up to the server limit, 4 by default). Cannot be combined with a poll.", + "Answer choices for a poll, one per item (2 to 4 on most servers). Cannot be combined with media.": "Answer choices for a poll, one per item (2 to 4 on most servers). Cannot be combined with media.", + "How long the poll stays open, in seconds. Mastodon accepts 300 (5 minutes) to 2629746 (1 month). Required with Poll Options.": "How long the poll stays open, in seconds. Mastodon accepts 300 (5 minutes) to 2629746 (1 month). Required with Poll Options.", + "Whether voters may pick more than one option. Leave empty for single choice.": "Whether voters may pick more than one option. Leave empty for single choice.", + "Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and returns the original post instead of creating a duplicate when the same key is sent again.": "Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and returns the original post instead of creating a duplicate when the same key is sent again.", + "When to publish, as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.": "When to publish, as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.", + "Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and ignores a duplicate submission with the same key.": "Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and ignores a duplicate submission with the same key.", + "Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Create Status or Search.": "Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Create Status or Search.", + "Local ID of your own status to edit. Obtain it from Create Status, Get Status or List Account Statuses.": "Local ID of your own status to edit. Obtain it from Create Status, Get Status or List Account Statuses.", + "New text of the post. Leave empty to keep the current text.": "New text of the post. Leave empty to keep the current text.", + "New content warning. Leave empty to keep the current one.": "New content warning. Leave empty to keep the current one.", + "Leave empty to keep the current setting.": "Leave empty to keep the current setting.", + "New ISO 639-1 language code, for example en. Leave empty to keep the current language.": "New ISO 639-1 language code, for example en. Leave empty to keep the current language.", + "Replaces the attached media with these IDs (from Upload Media or the current media_attachments). Leave empty to keep the current media.": "Replaces the attached media with these IDs (from Upload Media or the current media_attachments). Leave empty to keep the current media.", + "New poll choices, one per item. Changing them resets all votes. Leave empty to keep the current poll.": "New poll choices, one per item. Changing them resets all votes. Leave empty to keep the current poll.", + "New poll duration from now, in seconds (300 to 2629746). Leave empty to keep the current closing time.": "New poll duration from now, in seconds (300 to 2629746). Leave empty to keep the current closing time.", + "Changing this resets all votes. Leave empty to keep the current setting.": "Changing this resets all votes. Leave empty to keep the current setting.", + "Local ID of your own status to delete. Obtain it from Create Status, Get Status or List Account Statuses.": "Local ID of your own status to delete. Obtain it from Create Status, Get Status or List Account Statuses.", + "Delete the attached media right away instead of keeping it for about 24 hours for reuse. Requires Mastodon 4.4 or later; older servers ignore it.": "Delete the attached media right away instead of keeping it for about 24 hours for reuse. Requires Mastodon 4.4 or later; older servers ignore it.", + "Local ID of the status whose thread to read. Obtain it from a timeline, Get Status or Search.": "Local ID of the status whose thread to read. Obtain it from a timeline, Get Status or Search.", + "Local ID of the status. Obtain it from a timeline, Get Status or Search.": "Local ID of the status. Obtain it from a timeline, Get Status or Search.", + "Maximum number of accounts to return in this page. Defaults to 40, maximum 80.": "Maximum number of accounts to return in this page. Defaults to 40, maximum 80.", + "Cursor for the next (older) page. Pass the `next_max_id` value from the previous page of this same action. Leave empty for the newest page.": "Cursor for the next (older) page. Pass the `next_max_id` value from the previous page of this same action. Leave empty for the newest page.", + "Cursor for newer results. Pass the `prev_since_id` value from a previous page of this same action to page back towards newer items.": "Cursor for newer results. Pass the `prev_since_id` value from a previous page of this same action to page back towards newer items.", + "Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).": "Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).", + "Local ID of the status to boost. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).": "Local ID of the status to boost. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).", + "Who sees the boost. Leave empty to use the account default posting privacy.": "Who sees the boost. Leave empty to use the account default posting privacy.", + "Local ID of your own status. Obtain it from Create Status, Get Status or List Account Statuses.": "Local ID of your own status. Obtain it from Create Status, Get Status or List Account Statuses.", + "Maximum number of scheduled statuses to return in this page. Defaults to 20, maximum 40.": "Maximum number of scheduled statuses to return in this page. Defaults to 20, maximum 40.", + "Cursor for the page immediately newer than a previous page. Pass the `prev_min_id` value from a previous page of this same action.": "Cursor for the page immediately newer than a previous page. Pass the `prev_min_id` value from a previous page of this same action.", + "ID of the scheduled status. Obtain it from Schedule Status or List Scheduled Statuses.": "ID of the scheduled status. Obtain it from Schedule Status or List Scheduled Statuses.", + "New publication time as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.": "New publication time as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.", + "The image, video or audio file to upload.": "The image, video or audio file to upload.", + "Plain-text description of the media for accessibility (alt text).": "Plain-text description of the media for accessibility (alt text).", + "Focal point for thumbnail cropping as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.": "Focal point for thumbnail cropping as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.", + "ID of the unattached media. Obtain it from Upload Media.": "ID of the unattached media. Obtain it from Upload Media.", + "New plain-text description of the media for accessibility.": "New plain-text description of the media for accessibility.", + "New focal point as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.": "New focal point as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.", + "ID of the media attachment. Obtain it from Upload Media.": "ID of the media attachment. Obtain it from Upload Media.", + "New display name shown on the profile.": "New display name shown on the profile.", + "New profile bio text.": "New profile bio text.", + "Whether new followers must be approved manually. Leave empty to keep the current setting.": "Whether new followers must be approved manually. Leave empty to keep the current setting.", + "Whether the profile is flagged as an automated (bot) account. Leave empty to keep the current setting.": "Whether the profile is flagged as an automated (bot) account. Leave empty to keep the current setting.", + "Whether the account is featured in the profile directory. Leave empty to keep the current setting.": "Whether the account is featured in the profile directory. Leave empty to keep the current setting.", + "Default privacy for new posts. Leave empty to keep the current setting.": "Default privacy for new posts. Leave empty to keep the current setting.", + "Default ISO 639-1 language code for new posts, for example en.": "Default ISO 639-1 language code for new posts, for example en.", + "Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).": "Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).", + "Username for a local account (for example Gargron) or user@domain for a remote one (for example Gargron@mastodon.social). A leading @ is removed automatically.": "Username for a local account (for example Gargron) or user@domain for a remote one (for example Gargron@mastodon.social). A leading @ is removed automatically.", + "Text to search for, for example a name, username or user@domain.": "Text to search for, for example a name, username or user@domain.", + "Number of results to skip, for paging. Defaults to 0.": "Number of results to skip, for paging. Defaults to 0.", + "Look up unknown remote accounts via WebFinger (slower). Leave empty for the server default (no).": "Look up unknown remote accounts via WebFinger (slower). Leave empty for the server default (no).", + "Limit results to accounts the connected account follows. Leave empty for no restriction.": "Limit results to accounts the connected account follows. Leave empty for no restriction.", + "Local ID of the account. Obtain it from Get My Account, Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).": "Local ID of the account. Obtain it from Get My Account, Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).", + "Only statuses using this hashtag, without the # sign, for example mastodon.": "Only statuses using this hashtag, without the # sign, for example mastodon.", + "Maximum number of statuses to return in this page. Defaults to 20, maximum 40.": "Maximum number of statuses to return in this page. Defaults to 20, maximum 40.", + "Local account IDs to check, one per item. Obtain them from Lookup Account, Search Accounts or Get Account.": "Local account IDs to check, one per item. Obtain them from Lookup Account, Search Accounts or Get Account.", + "Local ID of the account to follow. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).": "Local ID of the account to follow. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).", + "Whether their boosts appear in your home timeline. Leave empty for the default (yes).": "Whether their boosts appear in your home timeline. Leave empty for the default (yes).", + "Whether to get a notification for each of their posts. Leave empty for the default (no).": "Whether to get a notification for each of their posts. Leave empty for the default (no).", + "Only show their posts in these ISO 639-1 languages, one code per item (for example en). Leave empty for all languages.": "Only show their posts in these ISO 639-1 languages, one code per item (for example en). Leave empty for all languages.", + "Local ID of the account to mute. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).": "Local ID of the account to mute. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).", + "Also hide notifications from this account. Leave empty for the default (yes).": "Also hide notifications from this account. Leave empty for the default (yes).", + "How long the mute lasts, in seconds, for example 86400 for one day. Leave empty or 0 to mute indefinitely.": "How long the mute lasts, in seconds, for example 86400 for one day. Leave empty or 0 to mute indefinitely.", + "Local ID of the account that requested to follow you. Obtain it from List Follow Requests.": "Local ID of the account that requested to follow you. Obtain it from List Follow Requests.", + "Only statuses posted on this server.": "Only statuses posted on this server.", + "Only statuses from other servers.": "Only statuses from other servers.", + "Hashtag without the # sign, for example mastodon. A leading # is removed automatically.": "Hashtag without the # sign, for example mastodon. A leading # is removed automatically.", + "Also include statuses using any of these additional hashtags, one per item, without #.": "Also include statuses using any of these additional hashtags, one per item, without #.", + "Only statuses that also use all of these hashtags, one per item, without #.": "Only statuses that also use all of these hashtags, one per item, without #.", + "Exclude statuses using any of these hashtags, one per item, without #.": "Exclude statuses using any of these hashtags, one per item, without #.", + "ID of one of your lists. Obtain it from List Lists or Create List.": "ID of one of your lists. Obtain it from List Lists or Create List.", + "Only return these notification types. Leave empty for all types.": "Only return these notification types. Leave empty for all types.", + "Leave out these notification types.": "Leave out these notification types.", + "Only notifications caused by this account (local ID from Lookup Account or Search Accounts).": "Only notifications caused by this account (local ID from Lookup Account or Search Accounts).", + "Maximum number of notifications to return in this page. Defaults to 40, maximum 80.": "Maximum number of notifications to return in this page. Defaults to 40, maximum 80.", + "ID of the notification. Obtain it from List Notifications.": "ID of the notification. Obtain it from List Notifications.", + "Only count these notification types. Leave empty for all types.": "Only count these notification types. Leave empty for all types.", + "Do not count these notification types.": "Do not count these notification types.", + "Only count notifications caused by this account (local account ID).": "Only count notifications caused by this account (local account ID).", + "Stop counting at this number. Defaults to 100, maximum 1000.": "Stop counting at this number. Defaults to 100, maximum 1000.", + "Search text, a hashtag, a handle such as Gargron@mastodon.social, or a status or profile URL such as https://mastodon.social/@Gargron/109372843234737004.": "Search text, a hashtag, a handle such as Gargron@mastodon.social, or a status or profile URL such as https://mastodon.social/@Gargron/109372843234737004.", + "Only return one kind of result. Leave empty for all three.": "Only return one kind of result. Leave empty for all three.", + "Fetch unknown remote accounts or URLs from their server. Leave empty to resolve automatically when the query is a URL or user@domain handle.": "Fetch unknown remote accounts or URLs from their server. Leave empty to resolve automatically when the query is a URL or user@domain handle.", + "Only include accounts the connected account follows. Leave empty for no restriction.": "Only include accounts the connected account follows. Leave empty for no restriction.", + "Only return statuses authored by this account (local account ID).": "Only return statuses authored by this account (local account ID).", + "Leave out hashtags not yet reviewed by moderators. Leave empty for the default (no).": "Leave out hashtags not yet reviewed by moderators. Leave empty for the default (no).", + "Maximum number of results per type to return in this page. Defaults to 20, maximum 40.": "Maximum number of results per type to return in this page. Defaults to 20, maximum 40.", + "Number of results to skip, for paging. Only applies when Result Type is set.": "Number of results to skip, for paging. Only applies when Result Type is set.", + "Only return results with an ID lower than this.": "Only return results with an ID lower than this.", + "Only return results with an ID immediately higher than this.": "Only return results with an ID immediately higher than this.", + "Name of the list, for example News.": "Name of the list, for example News.", + "Which replies appear in the list timeline. Leave empty for the default (list members).": "Which replies appear in the list timeline. Leave empty for the default (list members).", + "Whether posts from list members are removed from the home timeline. Leave empty for the default (no).": "Whether posts from list members are removed from the home timeline. Leave empty for the default (no).", + "New name of the list. Leave empty to keep the current title.": "New name of the list. Leave empty to keep the current title.", + "Which replies appear in the list timeline. Leave empty to keep the current setting.": "Which replies appear in the list timeline. Leave empty to keep the current setting.", + "Whether posts from list members are removed from the home timeline. Leave empty to keep the current setting.": "Whether posts from list members are removed from the home timeline. Leave empty to keep the current setting.", + "Local IDs of followed accounts to add, one per item. Obtain them from List Account Following, Lookup Account or Search Accounts.": "Local IDs of followed accounts to add, one per item. Obtain them from List Account Following, Lookup Account or Search Accounts.", + "Local IDs of the accounts to remove, one per item. Obtain them from List Accounts in List.": "Local IDs of the accounts to remove, one per item. Obtain them from List Accounts in List.", + "ID of the poll (the poll.id field of a status). Obtain it from Get Status or a timeline.": "ID of the poll (the poll.id field of a status). Obtain it from Get Status or a timeline.", + "0-based index of each option to vote for, one per item; for example 0 for the first option.": "0-based index of each option to vote for, one per item; for example 0 for the first option.", + "Maximum number of conversations to return in this page. Defaults to 20, maximum 40.": "Maximum number of conversations to return in this page. Defaults to 20, maximum 40.", + "ID of the direct-message conversation. Obtain it from List Conversations.": "ID of the direct-message conversation. Obtain it from List Conversations.", + "Hashtag name without the # sign, for example mastodon. A leading # is removed automatically.": "Hashtag name without the # sign, for example mastodon. A leading # is removed automatically.", + "Maximum number of hashtags to return in this page. Defaults to 100, maximum 200.": "Maximum number of hashtags to return in this page. Defaults to 100, maximum 200.", + "ID of the filter. Obtain it from List Filters or Create Filter.": "ID of the filter. Obtain it from List Filters or Create Filter.", + "Name of the filter, for example Spoilers.": "Name of the filter, for example Spoilers.", + "Where the filter applies. Choose at least one.": "Where the filter applies. Choose at least one.", + "Words or phrases to filter, one per item.": "Words or phrases to filter, one per item.", + "Applies to every keyword above. Leave empty for the default (no, match inside words too).": "Applies to every keyword above. Leave empty for the default (no, match inside words too).", + "What happens to matching posts. Leave empty for the default (warn).": "What happens to matching posts. Leave empty for the default (warn).", + "Seconds from now until the filter expires, for example 86400 for one day. Leave empty to never expire.": "Seconds from now until the filter expires, for example 86400 for one day. Leave empty to never expire.", + "New name of the filter. Leave empty to keep the current title.": "New name of the filter. Leave empty to keep the current title.", + "Replaces where the filter applies. Leave empty to keep the current contexts.": "Replaces where the filter applies. Leave empty to keep the current contexts.", + "What happens to matching posts. Leave empty to keep the current action.": "What happens to matching posts. Leave empty to keep the current action.", + "New expiry, in seconds from now. Leave empty to keep the current expiry.": "New expiry, in seconds from now. Leave empty to keep the current expiry.", + "Maximum number of hashtags to return in this page. Defaults to 10, maximum 20.": "Maximum number of hashtags to return in this page. Defaults to 10, maximum 20.", + "Number of results to skip, for paging through the trend list. Defaults to 0.": "Number of results to skip, for paging through the trend list. Defaults to 0.", + "Maximum number of links to return in this page. Defaults to 10, maximum 20.": "Maximum number of links to return in this page. Defaults to 10, maximum 20.", + "Public": "Public", + "Unlisted (public, hidden from public timelines)": "Unlisted (public, hidden from public timelines)", + "Followers only": "Followers only", + "Direct (mentioned accounts only)": "Direct (mentioned accounts only)", + "Yes": "Yes", + "No": "No", + "Unlisted": "Unlisted", + "Mention": "Mention", + "New post from an account you enabled notifications for": "New post from an account you enabled notifications for", + "Boost": "Boost", + "Follow": "Follow", + "Follow request": "Follow request", + "Favourite": "Favourite", + "Poll ended": "Poll ended", + "Boosted post edited": "Boosted post edited", + "Quote": "Quote", + "Quoted post edited": "Quoted post edited", + "Relationships severed": "Relationships severed", + "Moderation warning": "Moderation warning", + "Admin: new sign-up": "Admin: new sign-up", + "Admin: new report": "Admin: new report", + "Accounts": "Accounts", + "Statuses": "Statuses", + "Hashtags": "Hashtags", + "Any followed user": "Any followed user", + "Members of the list": "Members of the list", + "No one": "No one", + "Home and lists": "Home and lists", + "Notifications": "Notifications", + "Public timelines": "Public timelines", + "Conversations (threads)": "Conversations (threads)", + "Profiles": "Profiles", + "Warn (hide behind a warning)": "Warn (hide behind a warning)", + "Hide completely": "Hide completely", + "Blur media (Mastodon 4.4+)": "Blur media (Mastodon 4.4+)", + "New content warning. Leave empty to keep the current one; an existing content warning cannot be removed here.": "New content warning. Leave empty to keep the current one; an existing content warning cannot be removed here.", + "Replaces the attached media with these IDs (from Upload Media or the current media_attachments). Leave empty to keep the current media; all media cannot be detached here.": "Replaces the attached media with these IDs (from Upload Media or the current media_attachments). Leave empty to keep the current media; all media cannot be detached here.", + "New display name shown on the profile. Leave empty to keep the current display name.": "New display name shown on the profile. Leave empty to keep the current display name.", + "New profile bio text. Leave empty to keep the current bio.": "New profile bio text. Leave empty to keep the current bio." } \ No newline at end of file diff --git a/packages/pieces/community/mastodon/src/index.ts b/packages/pieces/community/mastodon/src/index.ts index 8565bb7ba293..4e26d846d5ee 100644 --- a/packages/pieces/community/mastodon/src/index.ts +++ b/packages/pieces/community/mastodon/src/index.ts @@ -6,6 +6,90 @@ import { } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { postStatus } from './lib/actions/post-status'; +import { createStatus } from './lib/actions/create-status'; +import { scheduleStatus } from './lib/actions/schedule-status'; +import { getStatus } from './lib/actions/get-status'; +import { editStatus } from './lib/actions/edit-status'; +import { deleteStatus } from './lib/actions/delete-status'; +import { getStatusContext } from './lib/actions/get-status-context'; +import { listStatusBoostedBy } from './lib/actions/list-status-boosted-by'; +import { listStatusFavouritedBy } from './lib/actions/list-status-favourited-by'; +import { favouriteStatus } from './lib/actions/favourite-status'; +import { unfavouriteStatus } from './lib/actions/unfavourite-status'; +import { boostStatus } from './lib/actions/boost-status'; +import { unboostStatus } from './lib/actions/unboost-status'; +import { bookmarkStatus } from './lib/actions/bookmark-status'; +import { unbookmarkStatus } from './lib/actions/unbookmark-status'; +import { muteConversation } from './lib/actions/mute-conversation'; +import { unmuteConversation } from './lib/actions/unmute-conversation'; +import { pinStatus } from './lib/actions/pin-status'; +import { unpinStatus } from './lib/actions/unpin-status'; +import { listScheduledStatuses } from './lib/actions/list-scheduled-statuses'; +import { getScheduledStatus } from './lib/actions/get-scheduled-status'; +import { rescheduleStatus } from './lib/actions/reschedule-status'; +import { cancelScheduledStatus } from './lib/actions/cancel-scheduled-status'; +import { uploadMedia } from './lib/actions/upload-media'; +import { getMedia } from './lib/actions/get-media'; +import { updateMedia } from './lib/actions/update-media'; +import { deleteMedia } from './lib/actions/delete-media'; +import { getMyAccount } from './lib/actions/get-my-account'; +import { updateMyProfile } from './lib/actions/update-my-profile'; +import { getAccount } from './lib/actions/get-account'; +import { lookupAccount } from './lib/actions/lookup-account'; +import { searchAccounts } from './lib/actions/search-accounts'; +import { listAccountStatuses } from './lib/actions/list-account-statuses'; +import { listAccountFollowers } from './lib/actions/list-account-followers'; +import { listAccountFollowing } from './lib/actions/list-account-following'; +import { getRelationships } from './lib/actions/get-relationships'; +import { followAccount } from './lib/actions/follow-account'; +import { unfollowAccount } from './lib/actions/unfollow-account'; +import { blockAccount } from './lib/actions/block-account'; +import { unblockAccount } from './lib/actions/unblock-account'; +import { muteAccount } from './lib/actions/mute-account'; +import { unmuteAccount } from './lib/actions/unmute-account'; +import { listBlockedAccounts } from './lib/actions/list-blocked-accounts'; +import { listMutedAccounts } from './lib/actions/list-muted-accounts'; +import { listFollowRequests } from './lib/actions/list-follow-requests'; +import { acceptFollowRequest } from './lib/actions/accept-follow-request'; +import { rejectFollowRequest } from './lib/actions/reject-follow-request'; +import { listBookmarks } from './lib/actions/list-bookmarks'; +import { listFavourites } from './lib/actions/list-favourites'; +import { getHomeTimeline } from './lib/actions/get-home-timeline'; +import { getPublicTimeline } from './lib/actions/get-public-timeline'; +import { getHashtagTimeline } from './lib/actions/get-hashtag-timeline'; +import { getListTimeline } from './lib/actions/get-list-timeline'; +import { listNotifications } from './lib/actions/list-notifications'; +import { getNotification } from './lib/actions/get-notification'; +import { dismissNotification } from './lib/actions/dismiss-notification'; +import { clearNotifications } from './lib/actions/clear-notifications'; +import { getUnreadNotificationCount } from './lib/actions/get-unread-notification-count'; +import { search } from './lib/actions/search'; +import { listLists } from './lib/actions/list-lists'; +import { getList } from './lib/actions/get-list'; +import { createList } from './lib/actions/create-list'; +import { updateList } from './lib/actions/update-list'; +import { deleteList } from './lib/actions/delete-list'; +import { listListAccounts } from './lib/actions/list-list-accounts'; +import { addAccountsToList } from './lib/actions/add-accounts-to-list'; +import { removeAccountsFromList } from './lib/actions/remove-accounts-from-list'; +import { getPoll } from './lib/actions/get-poll'; +import { voteInPoll } from './lib/actions/vote-in-poll'; +import { listConversations } from './lib/actions/list-conversations'; +import { markConversationRead } from './lib/actions/mark-conversation-read'; +import { deleteConversation } from './lib/actions/delete-conversation'; +import { getTag } from './lib/actions/get-tag'; +import { followTag } from './lib/actions/follow-tag'; +import { unfollowTag } from './lib/actions/unfollow-tag'; +import { listFollowedTags } from './lib/actions/list-followed-tags'; +import { listFilters } from './lib/actions/list-filters'; +import { getFilter } from './lib/actions/get-filter'; +import { createFilter } from './lib/actions/create-filter'; +import { updateFilter } from './lib/actions/update-filter'; +import { deleteFilter } from './lib/actions/delete-filter'; +import { getTrendingTags } from './lib/actions/get-trending-tags'; +import { getTrendingStatuses } from './lib/actions/get-trending-statuses'; +import { getTrendingLinks } from './lib/actions/get-trending-links'; +import { getInstanceInfo } from './lib/actions/get-instance-info'; const markdownDescription = ` **Base Url**: The base url of your Mastodon instance (e.g \`https://mastodon.social\`) @@ -14,8 +98,11 @@ const markdownDescription = ` 1. Go to your **Profile** -> **Preferences** -> **Development** -> **New Application** 2. Fill the Information -3. Click on **Create Application** -4. Copy access token from **Your access token** +3. Under **Scopes**, tick **read** and **write** (the form pre-selects only \`profile\`) +4. Click on **Create Application** +5. Copy access token from **Your access token** + +If an existing connection fails with a missing-scope error, edit the application in Mastodon, tick **read** and **write**, save (this regenerates the token) and paste the new token here. `; export const mastodonAuth = PieceAuth.CustomAuth({ @@ -43,11 +130,95 @@ export const mastodon = createPiece({ logoUrl: 'https://cdn.activepieces.com/pieces/mastodon.png', categories: [PieceCategory.COMMUNICATION], - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', authors: ["denieler","kishanprmr","MoShizzle","khaledmashaly","abuaboud"], auth: mastodonAuth, actions: [ postStatus, + createStatus, + scheduleStatus, + getStatus, + editStatus, + deleteStatus, + getStatusContext, + listStatusBoostedBy, + listStatusFavouritedBy, + favouriteStatus, + unfavouriteStatus, + boostStatus, + unboostStatus, + bookmarkStatus, + unbookmarkStatus, + muteConversation, + unmuteConversation, + pinStatus, + unpinStatus, + listScheduledStatuses, + getScheduledStatus, + rescheduleStatus, + cancelScheduledStatus, + uploadMedia, + getMedia, + updateMedia, + deleteMedia, + getMyAccount, + updateMyProfile, + getAccount, + lookupAccount, + searchAccounts, + listAccountStatuses, + listAccountFollowers, + listAccountFollowing, + getRelationships, + followAccount, + unfollowAccount, + blockAccount, + unblockAccount, + muteAccount, + unmuteAccount, + listBlockedAccounts, + listMutedAccounts, + listFollowRequests, + acceptFollowRequest, + rejectFollowRequest, + listBookmarks, + listFavourites, + getHomeTimeline, + getPublicTimeline, + getHashtagTimeline, + getListTimeline, + listNotifications, + getNotification, + dismissNotification, + clearNotifications, + getUnreadNotificationCount, + search, + listLists, + getList, + createList, + updateList, + deleteList, + listListAccounts, + addAccountsToList, + removeAccountsFromList, + getPoll, + voteInPoll, + listConversations, + markConversationRead, + deleteConversation, + getTag, + followTag, + unfollowTag, + listFollowedTags, + listFilters, + getFilter, + createFilter, + updateFilter, + deleteFilter, + getTrendingTags, + getTrendingStatuses, + getTrendingLinks, + getInstanceInfo, createCustomApiCallAction({ baseUrl: (auth) => auth?.props?.base_url.replace(/\/$/, '') + '/api/v1', diff --git a/packages/pieces/community/mastodon/src/lib/actions/accept-follow-request.ts b/packages/pieces/community/mastodon/src/lib/actions/accept-follow-request.ts new file mode 100644 index 000000000000..d3915584b5bc --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/accept-follow-request.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const acceptFollowRequest = createAction({ + auth: mastodonAuth, + name: 'accept_follow_request', + classification: 'WRITE', + displayName: 'Accept Follow Request', + description: 'Accept a pending follow request.', + audience: 'ai', + aiMetadata: { + description: + 'Accepts a pending follow request so the requesting account becomes a follower of the connected (locked) account. Get the account ID from List Follow Requests. Repeating the call fails with not found once the request is handled. Returns the updated relationship.', + idempotent: false, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account that requested to follow you. Obtain it from List Follow Requests.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/follow_requests/${encodeURIComponent(context.propsValue.account_id)}/authorize`, + operation: 'Accept Follow Request', + scope: 'write:follows', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/add-accounts-to-list.ts b/packages/pieces/community/mastodon/src/lib/actions/add-accounts-to-list.ts new file mode 100644 index 000000000000..b5730fa48469 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/add-accounts-to-list.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient, mastodonUtils } from '../common/client'; +import { listAcknowledgementOutputSchema } from '../output-schemas'; + +export const addAccountsToList = createAction({ + auth: mastodonAuth, + name: 'add_accounts_to_list', + classification: 'WRITE', + displayName: 'Add Accounts to List', + description: 'Add accounts you follow to one of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Adds one or more accounts to one of the connected account\'s lists. The connected account must already follow each of them (use Follow Account first), otherwise Mastodon rejects the call. Adding an account that is already in the list may be rejected, so check List Accounts in List before retrying.', + idempotent: false, + }, + outputSchema: listAcknowledgementOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + account_ids: Property.Array({ + displayName: 'Account IDs', + description: + 'Local IDs of followed accounts to add, one per item. Obtain them from List Account Following, Lookup Account or Search Accounts.', + required: true, + }), + }, + async run(context) { + const { list_id } = context.propsValue; + const accountIds = mastodonUtils.toStringArray(context.propsValue.account_ids); + if (accountIds === undefined) { + throw new Error('Provide at least one Account ID.'); + } + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/lists/${encodeURIComponent(list_id)}/accounts`, + operation: 'Add Accounts to List', + scope: 'write:lists', + body: { account_ids: accountIds }, + }); + return { success: true, list_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/block-account.ts b/packages/pieces/community/mastodon/src/lib/actions/block-account.ts new file mode 100644 index 000000000000..5165052dc6c4 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/block-account.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const blockAccount = createAction({ + auth: mastodonAuth, + name: 'block_account', + classification: 'DESTRUCTIVE', + displayName: 'Block Account', + description: 'Block an account.', + audience: 'ai', + aiMetadata: { + description: + 'Blocks an account: it can no longer see or interact with the connected account, and any follow in either direction is removed (re-following is needed after an unblock). Use Mute Account to hide someone without them noticing. Safe to retry. Returns the updated relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/block`, + operation: 'Block Account', + scope: 'write:blocks', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/bookmark-status.ts b/packages/pieces/community/mastodon/src/lib/actions/bookmark-status.ts new file mode 100644 index 000000000000..9cd0bb16aea8 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/bookmark-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const bookmarkStatus = createAction({ + auth: mastodonAuth, + name: 'bookmark_status', + classification: 'WRITE', + displayName: 'Bookmark Status', + description: 'Privately bookmark a status.', + audience: 'ai', + aiMetadata: { + description: + 'Bookmarks a status privately for the connected account (the author is not notified); read bookmarks back with List Bookmarks. Use Favourite Status for a public like. Safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/bookmark`, + operation: 'Bookmark Status', + scope: 'write:bookmarks', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/boost-status.ts b/packages/pieces/community/mastodon/src/lib/actions/boost-status.ts new file mode 100644 index 000000000000..3f05eea728ee --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/boost-status.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonUtils } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const boostStatus = createAction({ + auth: mastodonAuth, + name: 'boost_status', + classification: 'WRITE', + displayName: 'Boost Status', + description: 'Boost (reblog) a status to your followers.', + audience: 'ai', + aiMetadata: { + description: + 'Boosts (reblogs) a status so it appears to the connected account\'s followers and the author is notified. Visibility defaults to the account\'s default posting privacy, not public; set it explicitly to control reach. Boosting an already-boosted status returns the existing boost, so it is safe to retry. Use Unboost Status to undo.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status to boost. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + visibility: Property.StaticDropdown({ + displayName: 'Visibility', + description: + 'Who sees the boost. Leave empty to use the account default posting privacy.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Public', value: 'public' }, + { label: 'Unlisted', value: 'unlisted' }, + { label: 'Followers only', value: 'private' }, + ], + }, + }), + }, + async run(context) { + const { status_id, visibility } = context.propsValue; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(status_id)}/reblog`, + operation: 'Boost Status', + scope: 'write:statuses', + body: mastodonUtils.hasValue(visibility) ? { visibility } : {}, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/cancel-scheduled-status.ts b/packages/pieces/community/mastodon/src/lib/actions/cancel-scheduled-status.ts new file mode 100644 index 000000000000..a9bb80f19656 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/cancel-scheduled-status.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { cancelledScheduledStatusOutputSchema } from '../output-schemas'; + +export const cancelScheduledStatus = createAction({ + auth: mastodonAuth, + name: 'cancel_scheduled_status', + classification: 'DESTRUCTIVE', + displayName: 'Cancel Scheduled Status', + description: 'Cancel a scheduled status so it is never published.', + audience: 'ai', + aiMetadata: { + description: + 'Cancels and deletes a scheduled status so it is never published. Use Reschedule Status to move it instead. Irreversible; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: cancelledScheduledStatusOutputSchema, + props: { + scheduled_status_id: Property.ShortText({ + displayName: 'Scheduled Status ID', + description: + 'ID of the scheduled status. Obtain it from Schedule Status or List Scheduled Statuses.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/scheduled_statuses/${encodeURIComponent(context.propsValue.scheduled_status_id)}`, + operation: 'Cancel Scheduled Status', + scope: 'write:statuses', + }); + return { success: true, scheduled_status_id: context.propsValue.scheduled_status_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/clear-notifications.ts b/packages/pieces/community/mastodon/src/lib/actions/clear-notifications.ts new file mode 100644 index 000000000000..d5131a3f1283 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/clear-notifications.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { clearedNotificationsOutputSchema } from '../output-schemas'; + +export const clearNotifications = createAction({ + auth: mastodonAuth, + name: 'clear_notifications', + classification: 'DESTRUCTIVE', + displayName: 'Clear All Notifications', + description: 'Remove all notifications of the connected account.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently removes ALL notifications of the connected account in one call; this cannot be undone. Prefer Dismiss Notification to remove a single one. Repeating it is harmless.', + idempotent: true, + }, + outputSchema: clearedNotificationsOutputSchema, + props: { + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v1/notifications/clear', + operation: 'Clear All Notifications', + scope: 'write:notifications', + }); + return { success: true }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/create-filter.ts b/packages/pieces/community/mastodon/src/lib/actions/create-filter.ts new file mode 100644 index 000000000000..ee2755c91c12 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/create-filter.ts @@ -0,0 +1,103 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; + +const CONTEXT_OPTIONS = [ + { label: 'Home and lists', value: 'home' }, + { label: 'Notifications', value: 'notifications' }, + { label: 'Public timelines', value: 'public' }, + { label: 'Conversations (threads)', value: 'thread' }, + { label: 'Profiles', value: 'account' }, +]; + +const FILTER_ACTION_OPTIONS = [ + { label: 'Warn (hide behind a warning)', value: 'warn' }, + { label: 'Hide completely', value: 'hide' }, + { label: 'Blur media (Mastodon 4.4+)', value: 'blur' }, +]; +import { filterOutputSchema } from '../output-schemas'; + +export const createFilter = createAction({ + auth: mastodonAuth, + name: 'create_filter', + classification: 'WRITE', + displayName: 'Create Filter', + description: 'Create a content filter that hides or warns about matching posts.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a content filter for the connected account that warns about, hides or blurs posts matching the given keywords in the chosen contexts, optionally expiring after a duration. Each call creates a new filter; check List Filters first to avoid duplicates. Requires Mastodon 4.0 or later.', + idempotent: false, + }, + outputSchema: filterOutputSchema, + props: { + title: Property.ShortText({ + displayName: 'Title', + description: 'Name of the filter, for example Spoilers.', + required: true, + }), + context: Property.StaticMultiSelectDropdown({ + displayName: 'Apply In', + description: 'Where the filter applies. Choose at least one.', + required: true, + options: { disabled: false, options: CONTEXT_OPTIONS }, + }), + keywords: Property.Array({ + displayName: 'Keywords', + description: 'Words or phrases to filter, one per item.', + required: false, + }), + whole_word: mastodonProps.optionalBoolean({ + displayName: 'Match Whole Words Only', + description: 'Applies to every keyword above. Leave empty for the default (no, match inside words too).', + }), + filter_action: Property.StaticDropdown({ + displayName: 'Action', + description: 'What happens to matching posts. Leave empty for the default (warn).', + required: false, + options: { disabled: false, options: FILTER_ACTION_OPTIONS }, + }), + expires_in: Property.Number({ + displayName: 'Expires In (seconds)', + description: 'Seconds from now until the filter expires, for example 86400 for one day. Leave empty to never expire.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + if (props.context.length === 0) { + throw new Error('Choose at least one context in Apply In.'); + } + const keywords = mastodonUtils.toStringArray(props.keywords); + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v2/filters', + operation: 'Create Filter', + scope: 'write:filters', + minVersion: '4.0.0', + body: { + title: props.title, + context: props.context, + ...(mastodonUtils.hasValue(props.filter_action) ? { filter_action: props.filter_action } : {}), + ...(props.expires_in !== undefined && props.expires_in !== null + ? { expires_in: props.expires_in } + : {}), + ...(keywords !== undefined + ? { + keywords_attributes: keywords.map((keyword) => ({ + keyword, + ...(props.whole_word !== undefined ? { whole_word: props.whole_word } : {}), + })), + } + : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/create-list.ts b/packages/pieces/community/mastodon/src/lib/actions/create-list.ts new file mode 100644 index 000000000000..db3fa9a86931 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/create-list.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; + +const REPLIES_POLICY_OPTIONS = [ + { label: 'Any followed user', value: 'followed' }, + { label: 'Members of the list', value: 'list' }, + { label: 'No one', value: 'none' }, +]; +import { listOutputSchema } from '../output-schemas'; + +export const createList = createAction({ + auth: mastodonAuth, + name: 'create_list', + classification: 'WRITE', + displayName: 'Create List', + description: 'Create a new list for grouping accounts you follow.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a private list owned by the connected account, used to group followed accounts into a separate timeline. Add members with Add Accounts to List (they must already be followed). Each call creates a new list, even with the same title; check List Lists first to avoid duplicates.', + idempotent: false, + }, + outputSchema: listOutputSchema, + props: { + title: Property.ShortText({ + displayName: 'Title', + description: 'Name of the list, for example News.', + required: true, + }), + replies_policy: Property.StaticDropdown({ + displayName: 'Show Replies To', + description: 'Which replies appear in the list timeline. Leave empty for the default (list members).', + required: false, + options: { disabled: false, options: REPLIES_POLICY_OPTIONS }, + }), + exclusive: mastodonProps.optionalBoolean({ + displayName: 'Hide Members From Home Timeline', + description: 'Whether posts from list members are removed from the home timeline. Leave empty for the default (no).', + }), + }, + async run(context) { + const { title, replies_policy, exclusive } = context.propsValue; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v1/lists', + operation: 'Create List', + scope: 'write:lists', + body: { + title, + ...(mastodonUtils.hasValue(replies_policy) ? { replies_policy } : {}), + ...(exclusive !== undefined ? { exclusive } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/create-status.ts b/packages/pieces/community/mastodon/src/lib/actions/create-status.ts new file mode 100644 index 000000000000..eab8af22dc37 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/create-status.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const createStatus = createAction({ + auth: mastodonAuth, + name: 'create_status', + classification: 'WRITE', + displayName: 'Create Status', + description: 'Publish a status (toot), reply, or poll from the connected account.', + audience: 'ai', + aiMetadata: { + description: + 'Publishes a new status from the connected account, with control over visibility, content warning, language, attached media (IDs from Upload Media) or a poll; replies are this action with In Reply To set. Use Schedule Status instead to publish later. Media and a poll cannot be combined. Each call creates a new post; an optional Idempotency Key makes Mastodon ignore a duplicate submitted within about an hour, but retries after that duplicate the post.', + idempotent: false, + }, + outputSchema: statusOutputSchema, + props: { + ...mastodonProps.compose(), + idempotency_key: Property.ShortText({ + displayName: 'Idempotency Key', + description: + 'Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and returns the original post instead of creating a duplicate when the same key is sent again.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = mastodonUtils.buildComposeBody({ + status: props.status, + visibility: props.visibility, + inReplyToId: props.in_reply_to_id, + spoilerText: props.spoiler_text, + sensitive: props.sensitive, + language: props.language, + mediaIds: mastodonUtils.toStringArray(props.media_ids), + pollOptions: mastodonUtils.toStringArray(props.poll_options), + pollExpiresIn: props.poll_expires_in, + pollMultiple: props.poll_multiple, + }); + const idempotencyKey = props.idempotency_key; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v1/statuses', + operation: 'Create Status', + scope: 'write:statuses', + body, + headers: mastodonUtils.hasValue(idempotencyKey) + ? { 'Idempotency-Key': String(idempotencyKey) } + : undefined, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/delete-conversation.ts b/packages/pieces/community/mastodon/src/lib/actions/delete-conversation.ts new file mode 100644 index 000000000000..0f408de28831 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/delete-conversation.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { removedConversationOutputSchema } from '../output-schemas'; + +export const deleteConversation = createAction({ + auth: mastodonAuth, + name: 'delete_conversation', + classification: 'DESTRUCTIVE', + displayName: 'Remove Conversation', + description: 'Remove a direct-message conversation from your list.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a direct-message conversation from the connected account\'s conversation list only; the statuses themselves are not deleted and other participants keep it. Repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: removedConversationOutputSchema, + props: { + conversation_id: Property.ShortText({ + displayName: 'Conversation ID', + description: + 'ID of the direct-message conversation. Obtain it from List Conversations.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/conversations/${encodeURIComponent(context.propsValue.conversation_id)}`, + operation: 'Remove Conversation', + scope: 'write:conversations', + }); + return { success: true, conversation_id: context.propsValue.conversation_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/delete-filter.ts b/packages/pieces/community/mastodon/src/lib/actions/delete-filter.ts new file mode 100644 index 000000000000..6eee444d224c --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/delete-filter.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { deletedFilterOutputSchema } from '../output-schemas'; + +export const deleteFilter = createAction({ + auth: mastodonAuth, + name: 'delete_filter', + classification: 'DESTRUCTIVE', + displayName: 'Delete Filter', + description: 'Delete one of your content filters.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one of the connected account\'s content filters with all its keywords. Requires Mastodon 4.0 or later. Irreversible; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: deletedFilterOutputSchema, + props: { + filter_id: Property.ShortText({ + displayName: 'Filter ID', + description: + 'ID of the filter. Obtain it from List Filters or Create Filter.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v2/filters/${encodeURIComponent(context.propsValue.filter_id)}`, + operation: 'Delete Filter', + scope: 'write:filters', + minVersion: '4.0.0', + }); + return { success: true, filter_id: context.propsValue.filter_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/delete-list.ts b/packages/pieces/community/mastodon/src/lib/actions/delete-list.ts new file mode 100644 index 000000000000..9e7c1561f492 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/delete-list.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { listAcknowledgementOutputSchema } from '../output-schemas'; + +export const deleteList = createAction({ + auth: mastodonAuth, + name: 'delete_list', + classification: 'DESTRUCTIVE', + displayName: 'Delete List', + description: 'Delete one of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one of the connected account\'s lists (the accounts in it are not affected). Irreversible; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: listAcknowledgementOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: + 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/lists/${encodeURIComponent(context.propsValue.list_id)}`, + operation: 'Delete List', + scope: 'write:lists', + }); + return { success: true, list_id: context.propsValue.list_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/delete-media.ts b/packages/pieces/community/mastodon/src/lib/actions/delete-media.ts new file mode 100644 index 000000000000..f4fefc76d523 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/delete-media.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { deletedMediaOutputSchema } from '../output-schemas'; + +export const deleteMedia = createAction({ + auth: mastodonAuth, + name: 'delete_media', + classification: 'DESTRUCTIVE', + displayName: 'Delete Media', + description: 'Delete an uploaded media file that is not attached to a status.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes an uploaded media attachment that has not been attached to a status yet (attached media is removed by deleting the status). Requires Mastodon 4.4 or later. Irreversible; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: deletedMediaOutputSchema, + props: { + media_id: Property.ShortText({ + displayName: 'Media ID', + description: + 'ID of the media attachment. Obtain it from Upload Media.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/media/${encodeURIComponent(context.propsValue.media_id)}`, + operation: 'Delete Media', + scope: 'write:media', + minVersion: '4.4.0', + notFoundMessage: + 'Mastodon could not find this unattached media. Delete Media only works for media not yet attached to a status (and requires Mastodon 4.4 or later); attached media is removed together with its status.', + }); + return { success: true, media_id: context.propsValue.media_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/delete-status.ts b/packages/pieces/community/mastodon/src/lib/actions/delete-status.ts new file mode 100644 index 000000000000..278e126d71a6 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/delete-status.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { deletedStatusOutputSchema } from '../output-schemas'; + +export const deleteStatus = createAction({ + auth: mastodonAuth, + name: 'delete_status', + classification: 'DESTRUCTIVE', + displayName: 'Delete Status', + description: 'Permanently delete one of your own statuses.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a status posted by the connected account and returns the deleted status including its source text (useful to redraft). Followers and remote servers may already have received it, so deletion cannot recall copies. Only works on your own posts; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: deletedStatusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of your own status to delete. Obtain it from Create Status, Get Status or List Account Statuses.', + required: true, + }), + delete_media: Property.Checkbox({ + displayName: 'Delete Media Immediately', + description: + 'Delete the attached media right away instead of keeping it for about 24 hours for reuse. Requires Mastodon 4.4 or later; older servers ignore it.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}`, + operation: 'Delete Status', + scope: 'write:statuses', + query: context.propsValue.delete_media === true ? { delete_media: true } : undefined, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/dismiss-notification.ts b/packages/pieces/community/mastodon/src/lib/actions/dismiss-notification.ts new file mode 100644 index 000000000000..ce6e8ff0258a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/dismiss-notification.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { dismissedNotificationOutputSchema } from '../output-schemas'; + +export const dismissNotification = createAction({ + auth: mastodonAuth, + name: 'dismiss_notification', + classification: 'DESTRUCTIVE', + displayName: 'Dismiss Notification', + description: 'Dismiss a single notification.', + audience: 'ai', + aiMetadata: { + description: + 'Dismisses (removes) one notification of the connected account. Use Clear Notifications to remove all of them. Irreversible; repeating the call fails with not found.', + idempotent: false, + }, + outputSchema: dismissedNotificationOutputSchema, + props: { + notification_id: Property.ShortText({ + displayName: 'Notification ID', + description: + 'ID of the notification. Obtain it from List Notifications.', + required: true, + }), + }, + async run(context) { + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/notifications/${encodeURIComponent(context.propsValue.notification_id)}/dismiss`, + operation: 'Dismiss Notification', + scope: 'write:notifications', + }); + return { success: true, notification_id: context.propsValue.notification_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/edit-status.ts b/packages/pieces/community/mastodon/src/lib/actions/edit-status.ts new file mode 100644 index 000000000000..a58a04f36461 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/edit-status.ts @@ -0,0 +1,227 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonConnection, + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; + +const POLL_MIN_REMAINING_MS = (5 * 60 + 60) * 1000; +import { statusOutputSchema } from '../output-schemas'; + +export const editStatus = createAction({ + auth: mastodonAuth, + name: 'edit_status', + classification: 'WRITE', + displayName: 'Edit Status', + description: 'Edit the text, content warning, media or poll of one of your statuses.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a status posted by the connected account; only the fields you set change, everything else (text, content warning, sensitivity, media, poll) is carried over from the current post. An existing poll keeps its votes when you leave the poll fields empty, but its hidden-totals setting is reset to visible, and the edit is refused if the poll has ended or closes in under 5 minutes. Setting Poll Options or Allow Multiple Choices to different values resets all votes. Media IDs replace the attached media set. It cannot remove an existing content warning or detach all media; to do that, delete the status and create it again.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of your own status to edit. Obtain it from Create Status, Get Status or List Account Statuses.', + required: true, + }), + status: Property.LongText({ + displayName: 'Status Text', + description: 'New text of the post. Leave empty to keep the current text.', + required: false, + }), + spoiler_text: Property.ShortText({ + displayName: 'Content Warning', + description: 'New content warning. Leave empty to keep the current one; an existing content warning cannot be removed here.', + required: false, + }), + sensitive: mastodonProps.optionalBoolean({ + displayName: 'Mark Media as Sensitive', + description: 'Leave empty to keep the current setting.', + }), + language: Property.ShortText({ + displayName: 'Language', + description: + 'New ISO 639-1 language code, for example en. Leave empty to keep the current language.', + required: false, + }), + media_ids: Property.Array({ + displayName: 'Media IDs', + description: + 'Replaces the attached media with these IDs (from Upload Media or the current media_attachments). Leave empty to keep the current media; all media cannot be detached here.', + required: false, + }), + poll_options: Property.Array({ + displayName: 'Poll Options', + description: + 'New poll choices, one per item. Changing them resets all votes. Leave empty to keep the current poll.', + required: false, + }), + poll_expires_in: Property.Number({ + displayName: 'Poll Duration (seconds)', + description: + 'New poll duration from now, in seconds (300 to 2629746). Leave empty to keep the current closing time.', + required: false, + }), + poll_multiple: mastodonProps.optionalBoolean({ + displayName: 'Allow Multiple Choices', + description: 'Changing this resets all votes. Leave empty to keep the current setting.', + }), + }, + async run(context) { + const props = context.propsValue; + const auth = context.auth.props; + const statusPath = `/api/v1/statuses/${encodeURIComponent(props.status_id)}`; + + const source = await mastodonClient.request({ + auth, + method: HttpMethod.GET, + path: `${statusPath}/source`, + operation: 'Edit Status (read current source)', + scope: 'read:statuses', + }); + const current = await mastodonClient.request({ + auth, + method: HttpMethod.GET, + path: statusPath, + operation: 'Edit Status (read current status)', + scope: 'read:statuses', + }); + + const callerMediaIds = mastodonUtils.toStringArray(props.media_ids); + const mediaIds = + callerMediaIds ?? (current.media_attachments ?? []).map((attachment) => attachment.id); + const poll = resolvePoll({ + current: current.poll, + options: mastodonUtils.toStringArray(props.poll_options), + expiresIn: props.poll_expires_in, + multiple: props.poll_multiple, + }); + if (poll !== undefined && mediaIds.length > 0) { + throw new Error( + 'A status cannot have both media and a poll. Remove the Media IDs or the poll fields.' + ); + } + + const body = { + status: mastodonUtils.hasValue(props.status) ? props.status : source.text, + spoiler_text: mastodonUtils.hasValue(props.spoiler_text) + ? props.spoiler_text + : source.spoiler_text, + sensitive: props.sensitive ?? current.sensitive, + media_ids: mediaIds, + ...(mastodonUtils.hasValue(props.language) ? { language: props.language } : {}), + ...(poll !== undefined ? { poll } : {}), + }; + + return sendEdit({ auth, statusPath, body }); + }, +}); + +function resolvePoll({ + current, + options, + expiresIn, + multiple, +}: { + current: CurrentPoll | null | undefined; + options: string[] | undefined; + expiresIn: number | null | undefined; + multiple: boolean | undefined; +}): PollPayload | undefined { + const callerExpiresIn = expiresIn === null ? undefined : expiresIn; + const callerSetPoll = + options !== undefined || callerExpiresIn !== undefined || multiple !== undefined; + const hasCurrentPoll = current !== undefined && current !== null; + + if (!callerSetPoll) { + if (!hasCurrentPoll) { + return undefined; + } + return { + options: current.options.map((option) => option.title), + expires_in: remainingPollSeconds({ poll: current }), + multiple: current.multiple, + }; + } + + const resolvedOptions = + options ?? (hasCurrentPoll ? current.options.map((option) => option.title) : undefined); + if (resolvedOptions === undefined) { + throw new Error( + 'This status has no poll. Provide Poll Options and Poll Duration to add one.' + ); + } + const resolvedExpiresIn = + callerExpiresIn ?? + (hasCurrentPoll ? remainingPollSeconds({ poll: current }) : undefined); + if (resolvedExpiresIn === undefined) { + throw new Error('Poll Duration (seconds) is required when adding a poll.'); + } + return { + options: resolvedOptions, + expires_in: resolvedExpiresIn, + multiple: multiple ?? (hasCurrentPoll ? current.multiple : false), + }; +} + +function remainingPollSeconds({ poll }: { poll: CurrentPoll }): number { + const expiresAt = poll.expires_at === null ? Number.NaN : new Date(poll.expires_at).getTime(); + const remainingMs = expiresAt - Date.now(); + if (poll.expired || Number.isNaN(expiresAt) || remainingMs < POLL_MIN_REMAINING_MS) { + throw new Error( + 'This status has a poll that has ended or closes in under 5 minutes. Mastodon re-validates the poll on every edit (5 minutes to 1 month), so the edit would fail or delete the poll. Nothing was changed.' + ); + } + return Math.floor(remainingMs / 1000); +} + +async function sendEdit({ + auth, + statusPath, + body, +}: { + auth: MastodonConnection; + statusPath: string; + body: Record; +}): Promise { + return mastodonClient.request({ + auth, + method: HttpMethod.PUT, + path: statusPath, + operation: 'Edit Status', + scope: 'write:statuses', + body, + }); +} + +type StatusSource = { + text: string; + spoiler_text: string; +}; + +type CurrentPoll = { + expires_at: string | null; + expired: boolean; + multiple: boolean; + options: { title: string }[]; +}; + +type StatusForEdit = { + sensitive: boolean; + media_attachments?: { id: string }[]; + poll?: CurrentPoll | null; +}; + +type PollPayload = { + options: string[]; + expires_in: number; + multiple: boolean; +}; diff --git a/packages/pieces/community/mastodon/src/lib/actions/favourite-status.ts b/packages/pieces/community/mastodon/src/lib/actions/favourite-status.ts new file mode 100644 index 000000000000..69dc278c455a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/favourite-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const favouriteStatus = createAction({ + auth: mastodonAuth, + name: 'favourite_status', + classification: 'WRITE', + displayName: 'Favourite Status', + description: 'Add a status to your favourites.', + audience: 'ai', + aiMetadata: { + description: + 'Favourites (likes) a status as the connected account; the author is notified. Use Bookmark Status to save privately instead. Favouriting an already-favourited status changes nothing, so it is safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/favourite`, + operation: 'Favourite Status', + scope: 'write:favourites', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/follow-account.ts b/packages/pieces/community/mastodon/src/lib/actions/follow-account.ts new file mode 100644 index 000000000000..f9abeb091e38 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/follow-account.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const followAccount = createAction({ + auth: mastodonAuth, + name: 'follow_account', + classification: 'WRITE', + displayName: 'Follow Account', + description: 'Follow an account, or send a follow request to a locked account.', + audience: 'ai', + aiMetadata: { + description: + 'Follows an account as the connected account (for a locked account this sends a follow request); the target is notified. Calling it again on an account you already follow does not error and just updates the boost, notification and language options. Returns the relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account to follow. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).', + required: true, + }), + reblogs: mastodonProps.optionalBoolean({ + displayName: 'Show Their Boosts', + description: 'Whether their boosts appear in your home timeline. Leave empty for the default (yes).', + }), + notify: mastodonProps.optionalBoolean({ + displayName: 'Notify When They Post', + description: 'Whether to get a notification for each of their posts. Leave empty for the default (no).', + }), + languages: Property.Array({ + displayName: 'Languages', + description: + 'Only show their posts in these ISO 639-1 languages, one code per item (for example en). Leave empty for all languages.', + required: false, + }), + }, + async run(context) { + const { account_id, reblogs, notify, languages } = context.propsValue; + const languageCodes = mastodonUtils.toStringArray(languages); + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(account_id)}/follow`, + operation: 'Follow Account', + scope: 'write:follows', + body: { + ...(reblogs !== undefined ? { reblogs } : {}), + ...(notify !== undefined ? { notify } : {}), + ...(languageCodes !== undefined ? { languages: languageCodes } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/follow-tag.ts b/packages/pieces/community/mastodon/src/lib/actions/follow-tag.ts new file mode 100644 index 000000000000..281bddc4e4f6 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/follow-tag.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { tagOutputSchema } from '../output-schemas'; + +export const followTag = createAction({ + auth: mastodonAuth, + name: 'follow_tag', + classification: 'WRITE', + displayName: 'Follow Hashtag', + description: 'Follow a hashtag so its posts appear in your home timeline.', + audience: 'ai', + aiMetadata: { + description: + 'Follows a hashtag so posts using it appear in the connected account\'s home timeline. Requires Mastodon 4.0 or later; repeating it is harmless on 4.1 and later. Returns the hashtag.', + idempotent: true, + }, + outputSchema: tagOutputSchema, + props: { + tag_name: Property.ShortText({ + displayName: 'Hashtag', + description: + 'Hashtag name without the # sign, for example mastodon. A leading # is removed automatically.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/tags/${encodeURIComponent(context.propsValue.tag_name.replace(/^#/, ''))}/follow`, + operation: 'Follow Hashtag', + scope: 'write:follows', + minVersion: '4.0.0', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-account.ts b/packages/pieces/community/mastodon/src/lib/actions/get-account.ts new file mode 100644 index 000000000000..60a24f6cc4e5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-account.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { accountOutputSchema } from '../output-schemas'; + +export const getAccount = createAction({ + auth: mastodonAuth, + name: 'get_account', + classification: 'READ', + displayName: 'Get Account', + description: 'Get a Mastodon account profile by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches an account profile by its local ID. If you only have a handle, use Lookup Account (known accounts) or Search with resolve (remote accounts) to get the ID first. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}`, + operation: 'Get Account', + scope: 'read:accounts', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-filter.ts b/packages/pieces/community/mastodon/src/lib/actions/get-filter.ts new file mode 100644 index 000000000000..18d29c697bf5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-filter.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { filterOutputSchema } from '../output-schemas'; + +export const getFilter = createAction({ + auth: mastodonAuth, + name: 'get_filter', + classification: 'READ', + displayName: 'Get Filter', + description: 'Get one of your content filters by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one of the connected account\'s content filters by ID, with its keywords, contexts and action. Use List Filters to find IDs. Requires Mastodon 4.0 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: filterOutputSchema, + props: { + filter_id: Property.ShortText({ + displayName: 'Filter ID', + description: + 'ID of the filter. Obtain it from List Filters or Create Filter.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v2/filters/${encodeURIComponent(context.propsValue.filter_id)}`, + operation: 'Get Filter', + scope: 'read:filters', + minVersion: '4.0.0', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-hashtag-timeline.ts b/packages/pieces/community/mastodon/src/lib/actions/get-hashtag-timeline.ts new file mode 100644 index 000000000000..b2a80a34909c --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-hashtag-timeline.ts @@ -0,0 +1,94 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const getHashtagTimeline = createAction({ + auth: mastodonAuth, + name: 'get_hashtag_timeline', + classification: 'SEARCH', + displayName: 'Get Hashtag Timeline', + description: 'Get recent public statuses that use a hashtag.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of recent public statuses using a hashtag, newest first, with cursors for further pages; combine extra tags with any, all or none. Use Search for free-text queries. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + hashtag: Property.ShortText({ + displayName: 'Hashtag', + description: 'Hashtag without the # sign, for example mastodon. A leading # is removed automatically.', + required: true, + }), + any: Property.Array({ + displayName: 'Any of These Tags', + description: 'Also include statuses using any of these additional hashtags, one per item, without #.', + required: false, + }), + all: Property.Array({ + displayName: 'All of These Tags', + description: 'Only statuses that also use all of these hashtags, one per item, without #.', + required: false, + }), + none: Property.Array({ + displayName: 'None of These Tags', + description: 'Exclude statuses using any of these hashtags, one per item, without #.', + required: false, + }), + local: Property.Checkbox({ + displayName: 'Only Local Statuses', + required: false, + defaultValue: false, + }), + remote: Property.Checkbox({ + displayName: 'Only Remote Statuses', + required: false, + defaultValue: false, + }), + only_media: Property.Checkbox({ + displayName: 'Only Statuses With Media', + required: false, + defaultValue: false, + }), + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const props = context.propsValue; + const hashtag = props.hashtag.trim().replace(/^#/, ''); + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`, + operation: 'Get Hashtag Timeline', + scope: 'read:statuses', + query: { + any: stripHashes({ tags: mastodonUtils.toStringArray(props.any) }), + all: stripHashes({ tags: mastodonUtils.toStringArray(props.all) }), + none: stripHashes({ tags: mastodonUtils.toStringArray(props.none) }), + local: props.local === true ? true : undefined, + remote: props.remote === true ? true : undefined, + only_media: props.only_media === true ? true : undefined, + limit: props.limit, + max_id: props.max_id, + since_id: props.since_id, + min_id: props.min_id, + }, + }); + return { statuses: items, ...cursors }; + }, +}); + +function stripHashes({ tags }: { tags: string[] | undefined }): string[] | undefined { + return tags?.map((tag) => tag.replace(/^#/, '')); +} diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-home-timeline.ts b/packages/pieces/community/mastodon/src/lib/actions/get-home-timeline.ts new file mode 100644 index 000000000000..56ee2dc7b88e --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-home-timeline.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const getHomeTimeline = createAction({ + auth: mastodonAuth, + name: 'get_home_timeline', + classification: 'SEARCH', + displayName: 'Get Home Timeline', + description: 'Get statuses from accounts and hashtags you follow.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of the connected account\'s home timeline (posts and boosts from followed accounts and hashtags), newest first, with cursors for older or newer pages. Use Get Public Timeline for posts from everyone. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/timelines/home', + operation: 'Get Home Timeline', + scope: 'read:statuses', + query: { limit, max_id, since_id, min_id }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-instance-info.ts b/packages/pieces/community/mastodon/src/lib/actions/get-instance-info.ts new file mode 100644 index 000000000000..480d44ee2d19 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-instance-info.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { instanceOutputSchema } from '../output-schemas'; + +export const getInstanceInfo = createAction({ + auth: mastodonAuth, + name: 'get_instance_info', + classification: 'READ', + displayName: 'Get Instance Info', + description: 'Get information about the connected Mastodon server.', + audience: 'ai', + aiMetadata: { + description: + 'Returns information about the connected Mastodon server: version, API versions, character limit, media and poll limits, registration and contact details. Use it to check limits before posting or to see whether a newer endpoint is supported. Requires Mastodon 4.0 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: instanceOutputSchema, + props: { + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v2/instance', + operation: 'Get Instance Info', + minVersion: '4.0.0', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-list-timeline.ts b/packages/pieces/community/mastodon/src/lib/actions/get-list-timeline.ts new file mode 100644 index 000000000000..1f41c54108a1 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-list-timeline.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const getListTimeline = createAction({ + auth: mastodonAuth, + name: 'get_list_timeline', + classification: 'SEARCH', + displayName: 'Get List Timeline', + description: 'Get statuses from the members of one of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of statuses from the members of one of the connected account\'s lists, newest first, with cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: + 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/timelines/list/${encodeURIComponent(context.propsValue.list_id)}`, + operation: 'Get List Timeline', + scope: 'read:lists', + query: { limit, max_id, since_id, min_id }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-list.ts b/packages/pieces/community/mastodon/src/lib/actions/get-list.ts new file mode 100644 index 000000000000..fcbb5eacc040 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-list.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { listOutputSchema } from '../output-schemas'; + +export const getList = createAction({ + auth: mastodonAuth, + name: 'get_list', + classification: 'READ', + displayName: 'Get List', + description: 'Get one of your lists by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one of the connected account\'s lists by ID (title, replies policy, exclusive flag). Use List Lists to find IDs and List Accounts in List for its members. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: + 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/lists/${encodeURIComponent(context.propsValue.list_id)}`, + operation: 'Get List', + scope: 'read:lists', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-media.ts b/packages/pieces/community/mastodon/src/lib/actions/get-media.ts new file mode 100644 index 000000000000..5eacfb37590a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-media.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient } from '../common/client'; +import { mappedMediaOutputSchema } from '../output-schemas'; + +export const getMedia = createAction({ + auth: mastodonAuth, + name: 'get_media', + classification: 'READ', + displayName: 'Get Media', + description: 'Check an uploaded media file, including whether it has finished processing.', + audience: 'ai', + aiMetadata: { + description: + 'Reads an uploaded media attachment to check whether processing has finished (processing false and url non-null); poll it after Upload Media before attaching large video or audio. Only works for media not yet attached to a status; to read attached media use Get Status (media_attachments). Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: mappedMediaOutputSchema, + props: { + media_id: Property.ShortText({ + displayName: 'Media ID', + description: 'ID of the unattached media. Obtain it from Upload Media.', + required: true, + }), + }, + async run(context) { + const response = await mastodonClient.sendRequest({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/media/${encodeURIComponent(context.propsValue.media_id)}`, + operation: 'Get Media', + scope: 'write:media', + notFoundMessage: + 'Mastodon could not find this media. Get Media only works for media not yet attached to a status; to read attached media use Get Status (media_attachments).', + }); + const media = response.body; + return { + id: media.id, + type: media.type, + url: media.url ?? null, + preview_url: media.preview_url ?? null, + description: media.description ?? null, + processing: response.status === 206, + }; + }, +}); + +type MediaAttachment = { + id: string; + type: string; + url: string | null; + preview_url: string | null; + description: string | null; +}; diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-my-account.ts b/packages/pieces/community/mastodon/src/lib/actions/get-my-account.ts new file mode 100644 index 000000000000..e3e318cc40a6 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-my-account.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { myAccountOutputSchema } from '../output-schemas'; + +export const getMyAccount = createAction({ + auth: mastodonAuth, + name: 'get_my_account', + classification: 'READ', + displayName: 'Get My Account', + description: 'Get the profile of the connected account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the connected account\'s own profile, including its ID, username, counts and default posting settings. Call it first to learn the account ID for actions like List Account Statuses. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: myAccountOutputSchema, + props: { + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/accounts/verify_credentials', + operation: 'Get My Account', + scope: 'profile or read:accounts', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-notification.ts b/packages/pieces/community/mastodon/src/lib/actions/get-notification.ts new file mode 100644 index 000000000000..f5bd40b9d543 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-notification.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { notificationOutputSchema } from '../output-schemas'; + +export const getNotification = createAction({ + auth: mastodonAuth, + name: 'get_notification', + classification: 'READ', + displayName: 'Get Notification', + description: 'Get a single notification by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one notification of the connected account by ID, including its type, the account that triggered it and the related status. Use List Notifications to find IDs. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: notificationOutputSchema, + props: { + notification_id: Property.ShortText({ + displayName: 'Notification ID', + description: + 'ID of the notification. Obtain it from List Notifications.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/notifications/${encodeURIComponent(context.propsValue.notification_id)}`, + operation: 'Get Notification', + scope: 'read:notifications', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-poll.ts b/packages/pieces/community/mastodon/src/lib/actions/get-poll.ts new file mode 100644 index 000000000000..64127a1a6b00 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-poll.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { pollOutputSchema } from '../output-schemas'; + +export const getPoll = createAction({ + auth: mastodonAuth, + name: 'get_poll', + classification: 'READ', + displayName: 'Get Poll', + description: 'Get a poll with its current results.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches a poll by ID with its options, vote counts, expiry and whether the connected account has voted. Use Vote in Poll to vote. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: pollOutputSchema, + props: { + poll_id: Property.ShortText({ + displayName: 'Poll ID', + description: + 'ID of the poll (the poll.id field of a status). Obtain it from Get Status or a timeline.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/polls/${encodeURIComponent(context.propsValue.poll_id)}`, + operation: 'Get Poll', + scope: 'read:statuses', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-public-timeline.ts b/packages/pieces/community/mastodon/src/lib/actions/get-public-timeline.ts new file mode 100644 index 000000000000..25248f973c8f --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-public-timeline.ts @@ -0,0 +1,63 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const getPublicTimeline = createAction({ + auth: mastodonAuth, + name: 'get_public_timeline', + classification: 'SEARCH', + displayName: 'Get Public Timeline', + description: 'Get recent public statuses known to the server.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of recent public statuses known to the connected server (optionally only local or only remote, or only with media), newest first, with cursors for further pages. Use Get Hashtag Timeline for a topic or Get Home Timeline for followed accounts. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + local: Property.Checkbox({ + displayName: 'Only Local Statuses', + description: 'Only statuses posted on this server.', + required: false, + defaultValue: false, + }), + remote: Property.Checkbox({ + displayName: 'Only Remote Statuses', + description: 'Only statuses from other servers.', + required: false, + defaultValue: false, + }), + only_media: Property.Checkbox({ + displayName: 'Only Statuses With Media', + required: false, + defaultValue: false, + }), + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const props = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/timelines/public', + operation: 'Get Public Timeline', + scope: 'read:statuses', + query: { + local: props.local === true ? true : undefined, + remote: props.remote === true ? true : undefined, + only_media: props.only_media === true ? true : undefined, + limit: props.limit, + max_id: props.max_id, + since_id: props.since_id, + min_id: props.min_id, + }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-relationships.ts b/packages/pieces/community/mastodon/src/lib/actions/get-relationships.ts new file mode 100644 index 000000000000..3aba75a0dee9 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-relationships.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonUtils } from '../common/client'; +import { relationshipsOutputSchema } from '../output-schemas'; + +export const getRelationships = createAction({ + auth: mastodonAuth, + name: 'get_relationships', + classification: 'READ', + displayName: 'Get Relationships', + description: 'Check how the connected account relates to other accounts.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the connected account\'s relationship with each given account: following, followed by, requested, blocking, muting and more. Use it to check state before Follow Account, Block Account or Mute Account. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: relationshipsOutputSchema, + props: { + account_ids: Property.Array({ + displayName: 'Account IDs', + description: + 'Local account IDs to check, one per item. Obtain them from Lookup Account, Search Accounts or Get Account.', + required: true, + }), + }, + async run(context) { + const accountIds = mastodonUtils.toStringArray(context.propsValue.account_ids); + if (accountIds === undefined) { + throw new Error('Provide at least one Account ID.'); + } + const relationships = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/accounts/relationships', + operation: 'Get Relationships', + scope: 'read:follows', + query: { id: accountIds }, + }); + return { relationships, count: relationships.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-scheduled-status.ts b/packages/pieces/community/mastodon/src/lib/actions/get-scheduled-status.ts new file mode 100644 index 000000000000..b4d9ef19c092 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-scheduled-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { scheduledStatusOutputSchema } from '../output-schemas'; + +export const getScheduledStatus = createAction({ + auth: mastodonAuth, + name: 'get_scheduled_status', + classification: 'READ', + displayName: 'Get Scheduled Status', + description: 'Get a scheduled status by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one scheduled (not yet published) status by ID, including its scheduled time and parameters. Use List Scheduled Statuses to find IDs. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: scheduledStatusOutputSchema, + props: { + scheduled_status_id: Property.ShortText({ + displayName: 'Scheduled Status ID', + description: + 'ID of the scheduled status. Obtain it from Schedule Status or List Scheduled Statuses.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/scheduled_statuses/${encodeURIComponent(context.propsValue.scheduled_status_id)}`, + operation: 'Get Scheduled Status', + scope: 'read:statuses', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-status-context.ts b/packages/pieces/community/mastodon/src/lib/actions/get-status-context.ts new file mode 100644 index 000000000000..e958bf088421 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-status-context.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusContextOutputSchema } from '../output-schemas'; + +export const getStatusContext = createAction({ + auth: mastodonAuth, + name: 'get_status_context', + classification: 'READ', + displayName: 'Get Status Thread', + description: 'Get the ancestors and replies of a status.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the conversation thread around a status: its ancestors (the posts it replies to) and descendants (replies). Use it to read a whole discussion before replying; use Get Status for just the one post. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusContextOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status whose thread to read. Obtain it from a timeline, Get Status or Search.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/context`, + operation: 'Get Status Thread', + scope: 'read:statuses', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-status.ts b/packages/pieces/community/mastodon/src/lib/actions/get-status.ts new file mode 100644 index 000000000000..3527030add27 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const getStatus = createAction({ + auth: mastodonAuth, + name: 'get_status', + classification: 'READ', + displayName: 'Get Status', + description: 'Get a single status by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one status by its local ID, including text, author, counts, media attachments and poll. Use it to read a post before replying, editing or engaging with it; for a status URL from another server, first import it with Search (resolve) to get a local ID. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Create Status or Search.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}`, + operation: 'Get Status', + scope: 'read:statuses', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-tag.ts b/packages/pieces/community/mastodon/src/lib/actions/get-tag.ts new file mode 100644 index 000000000000..c8d473cfb216 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-tag.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { tagOutputSchema } from '../output-schemas'; + +export const getTag = createAction({ + auth: mastodonAuth, + name: 'get_tag', + classification: 'READ', + displayName: 'Get Hashtag', + description: 'Get information about a hashtag.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches a hashtag with its recent usage history and whether the connected account follows it. Use Get Hashtag Timeline for posts using it. Requires Mastodon 4.0 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: tagOutputSchema, + props: { + tag_name: Property.ShortText({ + displayName: 'Hashtag', + description: + 'Hashtag name without the # sign, for example mastodon. A leading # is removed automatically.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/tags/${encodeURIComponent(context.propsValue.tag_name.replace(/^#/, ''))}`, + operation: 'Get Hashtag', + minVersion: '4.0.0', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-trending-links.ts b/packages/pieces/community/mastodon/src/lib/actions/get-trending-links.ts new file mode 100644 index 000000000000..731aec9f6089 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-trending-links.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { trendingLinksOutputSchema } from '../output-schemas'; + +export const getTrendingLinks = createAction({ + auth: mastodonAuth, + name: 'get_trending_links', + classification: 'SEARCH', + displayName: 'Get Trending Links', + description: 'Get news links that are trending on the server.', + audience: 'ai', + aiMetadata: { + description: + 'Returns links (news articles and pages) currently shared the most on the connected server, with preview card details. Servers can disable trends, in which case the list is empty. Use offset to page. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: trendingLinksOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'links', defaultLimit: 10, maxLimit: 20 }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of results to skip, for paging through the trend list. Defaults to 0.', + required: false, + }), + }, + async run(context) { + const { limit, offset } = context.propsValue; + const links = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/trends/links', + operation: 'Get Trending Links', + query: { limit, offset }, + }); + return { links, count: links.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-trending-statuses.ts b/packages/pieces/community/mastodon/src/lib/actions/get-trending-statuses.ts new file mode 100644 index 000000000000..e46b154215e8 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-trending-statuses.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { trendingStatusesOutputSchema } from '../output-schemas'; + +export const getTrendingStatuses = createAction({ + auth: mastodonAuth, + name: 'get_trending_statuses', + classification: 'SEARCH', + displayName: 'Get Trending Statuses', + description: 'Get statuses that are trending on the server.', + audience: 'ai', + aiMetadata: { + description: + 'Returns statuses currently trending on the connected server (most interacted-with). Servers can disable trends, in which case the list is empty. Use offset to page. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: trendingStatusesOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of results to skip, for paging through the trend list. Defaults to 0.', + required: false, + }), + }, + async run(context) { + const { limit, offset } = context.propsValue; + const statuses = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/trends/statuses', + operation: 'Get Trending Statuses', + query: { limit, offset }, + }); + return { statuses, count: statuses.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-trending-tags.ts b/packages/pieces/community/mastodon/src/lib/actions/get-trending-tags.ts new file mode 100644 index 000000000000..3b538e21b8a0 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-trending-tags.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { trendingTagsOutputSchema } from '../output-schemas'; + +export const getTrendingTags = createAction({ + auth: mastodonAuth, + name: 'get_trending_tags', + classification: 'SEARCH', + displayName: 'Get Trending Hashtags', + description: 'Get hashtags that are trending on the server.', + audience: 'ai', + aiMetadata: { + description: + 'Returns hashtags currently trending on the connected server, with usage history. Servers can disable trends, in which case the list is empty. Use offset to page. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: trendingTagsOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'hashtags', defaultLimit: 10, maxLimit: 20 }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of results to skip, for paging through the trend list. Defaults to 0.', + required: false, + }), + }, + async run(context) { + const { limit, offset } = context.propsValue; + const tags = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/trends/tags', + operation: 'Get Trending Hashtags', + query: { limit, offset }, + }); + return { tags, count: tags.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/get-unread-notification-count.ts b/packages/pieces/community/mastodon/src/lib/actions/get-unread-notification-count.ts new file mode 100644 index 000000000000..8ee79b6d6817 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/get-unread-notification-count.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient, mastodonProps } from '../common/client'; +import { unreadNotificationCountOutputSchema } from '../output-schemas'; + +export const getUnreadNotificationCount = createAction({ + auth: mastodonAuth, + name: 'get_unread_notification_count', + classification: 'READ', + displayName: 'Get Unread Notification Count', + description: 'Get the number of unread notifications.', + audience: 'ai', + aiMetadata: { + description: + 'Returns how many unread notifications the connected account has (capped by Limit), optionally counting only some types or one account. Use List Notifications to read them. Requires Mastodon 4.3 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: unreadNotificationCountOutputSchema, + props: { + types: mastodonProps.notificationTypes({ + displayName: 'Count Only Types', + description: 'Only count these notification types. Leave empty for all types.', + }), + exclude_types: mastodonProps.notificationTypes({ + displayName: 'Exclude Types', + description: 'Do not count these notification types.', + }), + account_id: Property.ShortText({ + displayName: 'From Account ID', + description: 'Only count notifications caused by this account (local account ID).', + required: false, + }), + limit: Property.Number({ + displayName: 'Maximum Count', + description: 'Stop counting at this number. Defaults to 100, maximum 1000.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/notifications/unread_count', + operation: 'Get Unread Notification Count', + scope: 'read:notifications', + minVersion: '4.3.0', + query: { + types: props.types !== undefined && props.types.length > 0 ? props.types : undefined, + exclude_types: + props.exclude_types !== undefined && props.exclude_types.length > 0 + ? props.exclude_types + : undefined, + account_id: props.account_id, + limit: props.limit, + }, + }); + }, +}); + +type UnreadCount = { + count: number; +}; diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-account-followers.ts b/packages/pieces/community/mastodon/src/lib/actions/list-account-followers.ts new file mode 100644 index 000000000000..a12b8d5b2efa --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-account-followers.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listAccountFollowers = createAction({ + auth: mastodonAuth, + name: 'list_account_followers', + classification: 'SEARCH', + displayName: 'List Account Followers', + description: 'List the followers of an account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts following the given account, with Link-header cursors for further pages. Empty when the account hides its social graph. Use List Account Following for the reverse direction. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/followers`, + operation: 'List Account Followers', + scope: 'read:accounts', + query: { limit, max_id, since_id, min_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-account-following.ts b/packages/pieces/community/mastodon/src/lib/actions/list-account-following.ts new file mode 100644 index 000000000000..d2cb4791840e --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-account-following.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listAccountFollowing = createAction({ + auth: mastodonAuth, + name: 'list_account_following', + classification: 'SEARCH', + displayName: 'List Account Following', + description: 'List the accounts an account follows.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts the given account follows, with Link-header cursors for further pages. Empty when the account hides its social graph. Use List Account Followers for the reverse direction. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/following`, + operation: 'List Account Following', + scope: 'read:accounts', + query: { limit, max_id, since_id, min_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-account-statuses.ts b/packages/pieces/community/mastodon/src/lib/actions/list-account-statuses.ts new file mode 100644 index 000000000000..89c1c5756bd9 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-account-statuses.ts @@ -0,0 +1,79 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const listAccountStatuses = createAction({ + auth: mastodonAuth, + name: 'list_account_statuses', + classification: 'SEARCH', + displayName: 'List Account Statuses', + description: 'List the statuses posted by an account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of statuses posted or boosted by an account, newest first, with cursors for further pages; filter to media, pinned posts, a hashtag, or exclude replies and boosts. For your own posts, get your account ID from Get My Account. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account. Obtain it from Get My Account, Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).', + required: true, + }), + only_media: Property.Checkbox({ + displayName: 'Only Statuses With Media', + required: false, + defaultValue: false, + }), + exclude_replies: Property.Checkbox({ + displayName: 'Exclude Replies', + required: false, + defaultValue: false, + }), + exclude_reblogs: Property.Checkbox({ + displayName: 'Exclude Boosts', + required: false, + defaultValue: false, + }), + pinned: Property.Checkbox({ + displayName: 'Only Pinned Statuses', + required: false, + defaultValue: false, + }), + tagged: Property.ShortText({ + displayName: 'Hashtag Filter', + description: 'Only statuses using this hashtag, without the # sign, for example mastodon.', + required: false, + }), + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const props = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/accounts/${encodeURIComponent(props.account_id)}/statuses`, + operation: 'List Account Statuses', + scope: 'read:statuses', + query: { + only_media: props.only_media === true ? true : undefined, + exclude_replies: props.exclude_replies === true ? true : undefined, + exclude_reblogs: props.exclude_reblogs === true ? true : undefined, + pinned: props.pinned === true ? true : undefined, + tagged: props.tagged?.replace(/^#/, ''), + limit: props.limit, + max_id: props.max_id, + since_id: props.since_id, + min_id: props.min_id, + }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-blocked-accounts.ts b/packages/pieces/community/mastodon/src/lib/actions/list-blocked-accounts.ts new file mode 100644 index 000000000000..0b0d74e600cd --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-blocked-accounts.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listBlockedAccounts = createAction({ + auth: mastodonAuth, + name: 'list_blocked_accounts', + classification: 'SEARCH', + displayName: 'List Blocked Accounts', + description: 'List the accounts you have blocked.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts blocked by the connected account, with Link-header cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/blocks', + operation: 'List Blocked Accounts', + scope: 'read:blocks', + query: { limit, max_id, since_id, min_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-bookmarks.ts b/packages/pieces/community/mastodon/src/lib/actions/list-bookmarks.ts new file mode 100644 index 000000000000..dd9aa13b2d9e --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-bookmarks.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const listBookmarks = createAction({ + auth: mastodonAuth, + name: 'list_bookmarks', + classification: 'SEARCH', + displayName: 'List Bookmarks', + description: 'List the statuses you have bookmarked.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of statuses bookmarked by the connected account, newest bookmark first, with Link-header cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/bookmarks', + operation: 'List Bookmarks', + scope: 'read:bookmarks', + query: { limit, max_id, since_id, min_id }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-conversations.ts b/packages/pieces/community/mastodon/src/lib/actions/list-conversations.ts new file mode 100644 index 000000000000..125997b4cfd4 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-conversations.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { conversationPageOutputSchema } from '../output-schemas'; + +export const listConversations = createAction({ + auth: mastodonAuth, + name: 'list_conversations', + classification: 'SEARCH', + displayName: 'List Conversations', + description: 'List your direct-message conversations.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of the connected account\'s direct-message conversations (participants, last status, unread flag), with cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: conversationPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'conversations', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/conversations', + operation: 'List Conversations', + scope: 'read:statuses', + query: { limit, max_id, since_id, min_id }, + }); + return { conversations: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-favourites.ts b/packages/pieces/community/mastodon/src/lib/actions/list-favourites.ts new file mode 100644 index 000000000000..c970dd71f464 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-favourites.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { statusPageOutputSchema } from '../output-schemas'; + +export const listFavourites = createAction({ + auth: mastodonAuth, + name: 'list_favourites', + classification: 'SEARCH', + displayName: 'List Favourites', + description: 'List the statuses you have favourited.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of statuses favourited by the connected account, newest favourite first, with Link-header cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: statusPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/favourites', + operation: 'List Favourites', + scope: 'read:favourites', + query: { limit, max_id, since_id, min_id }, + }); + return { statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-filters.ts b/packages/pieces/community/mastodon/src/lib/actions/list-filters.ts new file mode 100644 index 000000000000..69076fb19f9a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-filters.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { filtersOutputSchema } from '../output-schemas'; + +export const listFilters = createAction({ + auth: mastodonAuth, + name: 'list_filters', + classification: 'SEARCH', + displayName: 'List Filters', + description: 'List all of your content filters.', + audience: 'ai', + aiMetadata: { + description: + 'Returns all content filters of the connected account with their keywords, contexts and actions. Requires Mastodon 4.0 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: filtersOutputSchema, + props: {}, + async run(context) { + const filters = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v2/filters', + operation: 'List Filters', + scope: 'read:filters', + minVersion: '4.0.0', + }); + return { filters, count: filters.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-follow-requests.ts b/packages/pieces/community/mastodon/src/lib/actions/list-follow-requests.ts new file mode 100644 index 000000000000..96558ad7f5de --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-follow-requests.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listFollowRequests = createAction({ + auth: mastodonAuth, + name: 'list_follow_requests', + classification: 'SEARCH', + displayName: 'List Follow Requests', + description: 'List pending requests to follow your account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts with a pending request to follow the connected (locked) account, with Link-header cursors for further pages. Handle them with Accept Follow Request or Reject Follow Request. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + }, + async run(context) { + const { limit, max_id, since_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/follow_requests', + operation: 'List Follow Requests', + scope: 'read:follows', + query: { limit, max_id, since_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-followed-tags.ts b/packages/pieces/community/mastodon/src/lib/actions/list-followed-tags.ts new file mode 100644 index 000000000000..cef3fbb492cb --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-followed-tags.ts @@ -0,0 +1,39 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { tagPageOutputSchema } from '../output-schemas'; + +export const listFollowedTags = createAction({ + auth: mastodonAuth, + name: 'list_followed_tags', + classification: 'SEARCH', + displayName: 'List Followed Hashtags', + description: 'List the hashtags you follow.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of hashtags followed by the connected account. Cursors come from Link headers, which servers before 4.1 do not send (then everything is returned in one page with null cursors). Requires Mastodon 4.0 or later. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: tagPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'hashtags', defaultLimit: 100, maxLimit: 200 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/followed_tags', + operation: 'List Followed Hashtags', + scope: 'read:follows', + minVersion: '4.0.0', + query: { limit, max_id, since_id, min_id }, + }); + return { tags: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-list-accounts.ts b/packages/pieces/community/mastodon/src/lib/actions/list-list-accounts.ts new file mode 100644 index 000000000000..8da065b22872 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-list-accounts.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listListAccounts = createAction({ + auth: mastodonAuth, + name: 'list_list_accounts', + classification: 'SEARCH', + displayName: 'List Accounts in List', + description: 'List the accounts that are members of one of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts in one of the connected account\'s lists, with Link-header cursors for further pages (the previous-page cursor comes back as prev_since_id). Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: + 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/lists/${encodeURIComponent(context.propsValue.list_id)}/accounts`, + operation: 'List Accounts in List', + scope: 'read:lists', + query: { limit, max_id, since_id, min_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-lists.ts b/packages/pieces/community/mastodon/src/lib/actions/list-lists.ts new file mode 100644 index 000000000000..a65aa41d8281 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-lists.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { listsOutputSchema } from '../output-schemas'; + +export const listLists = createAction({ + auth: mastodonAuth, + name: 'list_lists', + classification: 'SEARCH', + displayName: 'List Lists', + description: 'List all of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Returns all lists owned by the connected account with their IDs, titles and settings; use it to resolve a list name to the List ID other list actions need. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listsOutputSchema, + props: {}, + async run(context) { + const lists = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/lists', + operation: 'List Lists', + scope: 'read:lists', + }); + return { lists, count: lists.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-muted-accounts.ts b/packages/pieces/community/mastodon/src/lib/actions/list-muted-accounts.ts new file mode 100644 index 000000000000..27b7ed67814a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-muted-accounts.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { mutedAccountPageOutputSchema } from '../output-schemas'; + +export const listMutedAccounts = createAction({ + auth: mastodonAuth, + name: 'list_muted_accounts', + classification: 'SEARCH', + displayName: 'List Muted Accounts', + description: 'List the accounts you have muted.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts muted by the connected account (each with mute_expires_at for timed mutes), with Link-header cursors for further pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: mutedAccountPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + }, + async run(context) { + const { limit, max_id, since_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/mutes', + operation: 'List Muted Accounts', + scope: 'read:mutes', + query: { limit, max_id, since_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-notifications.ts b/packages/pieces/community/mastodon/src/lib/actions/list-notifications.ts new file mode 100644 index 000000000000..065e939993d1 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-notifications.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { notificationPageOutputSchema } from '../output-schemas'; + +export const listNotifications = createAction({ + auth: mastodonAuth, + name: 'list_notifications', + classification: 'SEARCH', + displayName: 'List Notifications', + description: 'List notifications of the connected account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of the connected account\'s notifications (mentions, follows, favourites, boosts, polls and more), newest first, with cursors for further pages; filter by type or by the account that caused them. Use Get Unread Notification Count for just a number. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: notificationPageOutputSchema, + props: { + types: mastodonProps.notificationTypes({ + displayName: 'Include Types', + description: 'Only return these notification types. Leave empty for all types.', + }), + exclude_types: mastodonProps.notificationTypes({ + displayName: 'Exclude Types', + description: 'Leave out these notification types.', + }), + account_id: Property.ShortText({ + displayName: 'From Account ID', + description: + 'Only notifications caused by this account (local ID from Lookup Account or Search Accounts).', + required: false, + }), + limit: mastodonProps.limit({ noun: 'notifications', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const props = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/notifications', + operation: 'List Notifications', + scope: 'read:notifications', + query: { + types: nonEmpty({ values: props.types }), + exclude_types: nonEmpty({ values: props.exclude_types }), + account_id: props.account_id, + limit: props.limit, + max_id: props.max_id, + since_id: props.since_id, + min_id: props.min_id, + }, + }); + return { notifications: items, ...cursors }; + }, +}); + +function nonEmpty({ values }: { values: string[] | undefined }): string[] | undefined { + return values === undefined || values.length === 0 ? undefined : values; +} diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-scheduled-statuses.ts b/packages/pieces/community/mastodon/src/lib/actions/list-scheduled-statuses.ts new file mode 100644 index 000000000000..55f53d58c504 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-scheduled-statuses.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { scheduledStatusPageOutputSchema } from '../output-schemas'; + +export const listScheduledStatuses = createAction({ + auth: mastodonAuth, + name: 'list_scheduled_statuses', + classification: 'SEARCH', + displayName: 'List Scheduled Statuses', + description: 'List your statuses scheduled for future publication.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of the connected account\'s scheduled (not yet published) statuses with cursors for further pages. Use Get Scheduled Status for one item. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: scheduledStatusPageOutputSchema, + props: { + limit: mastodonProps.limit({ noun: 'scheduled statuses', defaultLimit: 20, maxLimit: 40 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + min_id: mastodonProps.minId(), + }, + async run(context) { + const { limit, max_id, since_id, min_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/scheduled_statuses', + operation: 'List Scheduled Statuses', + scope: 'read:statuses', + query: { limit, max_id, since_id, min_id }, + }); + return { scheduled_statuses: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-status-boosted-by.ts b/packages/pieces/community/mastodon/src/lib/actions/list-status-boosted-by.ts new file mode 100644 index 000000000000..27c02111611a --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-status-boosted-by.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listStatusBoostedBy = createAction({ + auth: mastodonAuth, + name: 'list_status_boosted_by', + classification: 'SEARCH', + displayName: 'List Accounts That Boosted a Status', + description: 'List the accounts that boosted (reblogged) a status.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts that boosted a given status, with cursors for further pages (pass next_max_id as Max ID, prev_since_id as Since ID). Use List Accounts That Favourited a Status for favourites. Needs the read:accounts scope. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: 'Local ID of the status. Obtain it from a timeline, Get Status or Search.', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + }, + async run(context) { + const { status_id, limit, max_id, since_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/statuses/${encodeURIComponent(status_id)}/reblogged_by`, + operation: 'List Accounts That Boosted a Status', + scope: 'read:accounts', + query: { limit, max_id, since_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/list-status-favourited-by.ts b/packages/pieces/community/mastodon/src/lib/actions/list-status-favourited-by.ts new file mode 100644 index 000000000000..4e1d23586a52 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/list-status-favourited-by.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { accountPageOutputSchema } from '../output-schemas'; + +export const listStatusFavouritedBy = createAction({ + auth: mastodonAuth, + name: 'list_status_favourited_by', + classification: 'SEARCH', + displayName: 'List Accounts That Favourited a Status', + description: 'List the accounts that favourited a status.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one page of accounts that favourited a given status, with cursors for further pages (pass next_max_id as Max ID, prev_since_id as Since ID). Use List Accounts That Boosted a Status for boosts. Needs the read:accounts scope. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountPageOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: 'Local ID of the status. Obtain it from a timeline, Get Status or Search.', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + max_id: mastodonProps.maxId(), + since_id: mastodonProps.sinceId(), + }, + async run(context) { + const { status_id, limit, max_id, since_id } = context.propsValue; + const { items, ...cursors } = await mastodonClient.requestPage({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/api/v1/statuses/${encodeURIComponent(status_id)}/favourited_by`, + operation: 'List Accounts That Favourited a Status', + scope: 'read:accounts', + query: { limit, max_id, since_id }, + }); + return { accounts: items, ...cursors }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/lookup-account.ts b/packages/pieces/community/mastodon/src/lib/actions/lookup-account.ts new file mode 100644 index 000000000000..77db8b091e41 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/lookup-account.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { accountOutputSchema } from '../output-schemas'; + +export const lookupAccount = createAction({ + auth: mastodonAuth, + name: 'lookup_account', + classification: 'READ', + displayName: 'Lookup Account', + description: 'Find an account by its handle (username or user@domain).', + audience: 'ai', + aiMetadata: { + description: + 'Resolves a handle such as Gargron or Gargron@mastodon.social to the account profile and local ID, without a fuzzy search. Only finds accounts already known to this server; for a remote user@domain that 404s, use Search with resolve=true (type accounts). Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: accountOutputSchema, + props: { + acct: Property.ShortText({ + displayName: 'Handle', + description: + 'Username for a local account (for example Gargron) or user@domain for a remote one (for example Gargron@mastodon.social). A leading @ is removed automatically.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/accounts/lookup', + operation: 'Lookup Account', + scope: 'read:accounts', + notFoundMessage: + 'Mastodon found no account with this handle. Lookup Account only finds accounts already known to this server; for a remote user@domain, use Search with resolve=true (type accounts) to import it and get its local ID.', + query: { acct: context.propsValue.acct.trim().replace(/^@/, '') }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/mark-conversation-read.ts b/packages/pieces/community/mastodon/src/lib/actions/mark-conversation-read.ts new file mode 100644 index 000000000000..a7ec09f67e0e --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/mark-conversation-read.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { conversationOutputSchema } from '../output-schemas'; + +export const markConversationRead = createAction({ + auth: mastodonAuth, + name: 'mark_conversation_read', + classification: 'WRITE', + displayName: 'Mark Conversation as Read', + description: 'Mark a direct-message conversation as read.', + audience: 'ai', + aiMetadata: { + description: + 'Marks a direct-message conversation of the connected account as read. Get the conversation ID from List Conversations. Safe to retry. Returns the updated conversation.', + idempotent: true, + }, + outputSchema: conversationOutputSchema, + props: { + conversation_id: Property.ShortText({ + displayName: 'Conversation ID', + description: + 'ID of the direct-message conversation. Obtain it from List Conversations.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/conversations/${encodeURIComponent(context.propsValue.conversation_id)}/read`, + operation: 'Mark Conversation as Read', + scope: 'write:conversations', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/mute-account.ts b/packages/pieces/community/mastodon/src/lib/actions/mute-account.ts new file mode 100644 index 000000000000..fa0c16ae4c76 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/mute-account.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const muteAccount = createAction({ + auth: mastodonAuth, + name: 'mute_account', + classification: 'WRITE', + displayName: 'Mute Account', + description: 'Hide an account\'s posts, and optionally its notifications.', + audience: 'ai', + aiMetadata: { + description: + 'Mutes an account so its posts are hidden from the connected account (the muted account is not told). Unset Mute Notifications also hides their notifications; unset Duration mutes indefinitely. Calling it on an already-muted account overwrites the existing mute\'s duration and notification setting, so pass both to keep them. Use Block Account to also stop them seeing you. Returns the relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account to mute. Obtain it from Lookup Account, Search Accounts or Search (use resolve for a remote user@domain).', + required: true, + }), + notifications: mastodonProps.optionalBoolean({ + displayName: 'Mute Notifications', + description: 'Also hide notifications from this account. Leave empty for the default (yes).', + }), + duration: Property.Number({ + displayName: 'Duration (seconds)', + description: 'How long the mute lasts, in seconds, for example 86400 for one day. Leave empty or 0 to mute indefinitely.', + required: false, + }), + }, + async run(context) { + const { account_id, notifications, duration } = context.propsValue; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(account_id)}/mute`, + operation: 'Mute Account', + scope: 'write:mutes', + body: { + ...(notifications !== undefined ? { notifications } : {}), + ...(duration !== undefined && duration !== null ? { duration } : {}), + }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/mute-conversation.ts b/packages/pieces/community/mastodon/src/lib/actions/mute-conversation.ts new file mode 100644 index 000000000000..7656f30baf89 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/mute-conversation.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const muteConversation = createAction({ + auth: mastodonAuth, + name: 'mute_conversation', + classification: 'WRITE', + displayName: 'Mute Conversation', + description: 'Stop receiving notifications for a thread.', + audience: 'ai', + aiMetadata: { + description: + 'Mutes notifications from the conversation thread a status belongs to, for a thread the connected account participates in. Use Mute Account to silence a person instead. Safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/mute`, + operation: 'Mute Conversation', + scope: 'write:mutes', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/pin-status.ts b/packages/pieces/community/mastodon/src/lib/actions/pin-status.ts new file mode 100644 index 000000000000..cae35652acc3 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/pin-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const pinStatus = createAction({ + auth: mastodonAuth, + name: 'pin_status', + classification: 'WRITE', + displayName: 'Pin Status to Profile', + description: 'Feature one of your statuses at the top of your profile.', + audience: 'ai', + aiMetadata: { + description: + 'Pins one of the connected account\'s own public, unlisted or (Mastodon 3.5+) followers-only statuses to the top of its profile, visible to profile visitors; direct messages cannot be pinned and servers cap the number of pins. Pinning an already-pinned status may be rejected, so do not blindly retry. Returns the updated status.', + idempotent: false, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of your own status. Obtain it from Create Status, Get Status or List Account Statuses.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/pin`, + operation: 'Pin Status to Profile', + scope: 'write:accounts', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/post-status.ts b/packages/pieces/community/mastodon/src/lib/actions/post-status.ts index d88297420c57..4325528a350a 100644 --- a/packages/pieces/community/mastodon/src/lib/actions/post-status.ts +++ b/packages/pieces/community/mastodon/src/lib/actions/post-status.ts @@ -6,6 +6,7 @@ import { HttpMethod, } from '@activepieces/pieces-common'; import { mastodonAuth } from '../..'; +import { postStatusOutputSchema } from '../output-schemas'; const uploadMedia = async (media: ApFile, baseUrl: string, token: string) => { const formData = new FormData(); @@ -33,8 +34,9 @@ export const postStatus = createAction({ classification: 'WRITE', displayName: 'Post Status', description: 'Post a status to Mastodon', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a new status (toot) to a Mastodon instance from the authenticated account, optionally attaching a single media file uploaded alongside the post. Use to broadcast a message or share content on Mastodon. Requires status text; not idempotent — each call creates a separate post.', idempotent: false }, + outputSchema: postStatusOutputSchema, props: { status: Property.LongText({ displayName: 'Status', diff --git a/packages/pieces/community/mastodon/src/lib/actions/reject-follow-request.ts b/packages/pieces/community/mastodon/src/lib/actions/reject-follow-request.ts new file mode 100644 index 000000000000..2adb3e658848 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/reject-follow-request.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const rejectFollowRequest = createAction({ + auth: mastodonAuth, + name: 'reject_follow_request', + classification: 'WRITE', + displayName: 'Reject Follow Request', + description: 'Reject a pending follow request.', + audience: 'ai', + aiMetadata: { + description: + 'Rejects a pending follow request to the connected (locked) account. Get the account ID from List Follow Requests. Repeating the call fails with not found once the request is handled. Returns the updated relationship.', + idempotent: false, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account that requested to follow you. Obtain it from List Follow Requests.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/follow_requests/${encodeURIComponent(context.propsValue.account_id)}/reject`, + operation: 'Reject Follow Request', + scope: 'write:follows', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/remove-accounts-from-list.ts b/packages/pieces/community/mastodon/src/lib/actions/remove-accounts-from-list.ts new file mode 100644 index 000000000000..cecf985169be --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/remove-accounts-from-list.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient, mastodonUtils } from '../common/client'; +import { listAcknowledgementOutputSchema } from '../output-schemas'; + +export const removeAccountsFromList = createAction({ + auth: mastodonAuth, + name: 'remove_accounts_from_list', + classification: 'WRITE', + displayName: 'Remove Accounts from List', + description: 'Remove accounts from one of your lists.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one or more accounts from one of the connected account\'s lists; it does not unfollow them. Removing an account that is not in the list changes nothing, so it is safe to retry.', + idempotent: true, + }, + outputSchema: listAcknowledgementOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + account_ids: Property.Array({ + displayName: 'Account IDs', + description: + 'Local IDs of the accounts to remove, one per item. Obtain them from List Accounts in List.', + required: true, + }), + }, + async run(context) { + const { list_id } = context.propsValue; + const accountIds = mastodonUtils.toStringArray(context.propsValue.account_ids); + if (accountIds === undefined) { + throw new Error('Provide at least one Account ID.'); + } + await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/api/v1/lists/${encodeURIComponent(list_id)}/accounts`, + operation: 'Remove Accounts from List', + scope: 'write:lists', + query: { account_ids: accountIds }, + }); + return { success: true, list_id }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/reschedule-status.ts b/packages/pieces/community/mastodon/src/lib/actions/reschedule-status.ts new file mode 100644 index 000000000000..b4ea740cc3c0 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/reschedule-status.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonUtils } from '../common/client'; +import { scheduledStatusOutputSchema } from '../output-schemas'; + +export const rescheduleStatus = createAction({ + auth: mastodonAuth, + name: 'reschedule_status', + classification: 'WRITE', + displayName: 'Reschedule Status', + description: 'Change the publication time of a scheduled status.', + audience: 'ai', + aiMetadata: { + description: + 'Moves a scheduled status to a new publication time (at least 6 minutes ahead); its content cannot be changed here. Use Cancel Scheduled Status to drop it instead. Setting the same time again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: scheduledStatusOutputSchema, + props: { + scheduled_status_id: Property.ShortText({ + displayName: 'Scheduled Status ID', + description: + 'ID of the scheduled status. Obtain it from Schedule Status or List Scheduled Statuses.', + required: true, + }), + scheduled_at: Property.DateTime({ + displayName: 'Scheduled At', + description: + 'New publication time as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.', + required: true, + }), + }, + async run(context) { + const { scheduled_status_id, scheduled_at } = context.propsValue; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/api/v1/scheduled_statuses/${encodeURIComponent(scheduled_status_id)}`, + operation: 'Reschedule Status', + scope: 'write:statuses', + body: { scheduled_at: mastodonUtils.assertScheduledAt(scheduled_at) }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/schedule-status.ts b/packages/pieces/community/mastodon/src/lib/actions/schedule-status.ts new file mode 100644 index 000000000000..f65b65feb4d5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/schedule-status.ts @@ -0,0 +1,68 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; +import { scheduledStatusOutputSchema } from '../output-schemas'; + +export const scheduleStatus = createAction({ + auth: mastodonAuth, + name: 'schedule_status', + classification: 'WRITE', + displayName: 'Schedule Status', + description: 'Schedule a status to be published automatically at a future time.', + audience: 'ai', + aiMetadata: { + description: + 'Schedules a status to be published by Mastodon at a future date-time (at least 6 minutes ahead) and returns the scheduled status, not a published post. Use Create Status to post immediately; manage the result with List Scheduled Statuses, Reschedule Status or Cancel Scheduled Status. Each call schedules another post.', + idempotent: false, + }, + outputSchema: scheduledStatusOutputSchema, + props: { + scheduled_at: Property.DateTime({ + displayName: 'Scheduled At', + description: + 'When to publish, as an ISO 8601 date-time such as 2026-10-01T09:30:00Z. Must be at least 6 minutes in the future.', + required: true, + }), + ...mastodonProps.compose(), + idempotency_key: Property.ShortText({ + displayName: 'Idempotency Key', + description: + 'Optional unique string (for example a UUID). Mastodon keeps it for up to 1 hour and ignores a duplicate submission with the same key.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const scheduledAt = mastodonUtils.assertScheduledAt(props.scheduled_at); + const body = mastodonUtils.buildComposeBody({ + status: props.status, + visibility: props.visibility, + inReplyToId: props.in_reply_to_id, + spoilerText: props.spoiler_text, + sensitive: props.sensitive, + language: props.language, + mediaIds: mastodonUtils.toStringArray(props.media_ids), + pollOptions: mastodonUtils.toStringArray(props.poll_options), + pollExpiresIn: props.poll_expires_in, + pollMultiple: props.poll_multiple, + }); + const idempotencyKey = props.idempotency_key; + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v1/statuses', + operation: 'Schedule Status', + scope: 'write:statuses', + body: { ...body, scheduled_at: scheduledAt }, + headers: mastodonUtils.hasValue(idempotencyKey) + ? { 'Idempotency-Key': String(idempotencyKey) } + : undefined, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/search-accounts.ts b/packages/pieces/community/mastodon/src/lib/actions/search-accounts.ts new file mode 100644 index 000000000000..de291dcd9aa5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/search-accounts.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; +import { searchAccountsOutputSchema } from '../output-schemas'; + +export const searchAccounts = createAction({ + auth: mastodonAuth, + name: 'search_accounts', + classification: 'SEARCH', + displayName: 'Search Accounts', + description: 'Search for accounts by name or handle.', + audience: 'ai', + aiMetadata: { + description: + 'Searches accounts by display name, username or handle and returns matching profiles with their local IDs. Use Lookup Account for an exact handle, or Search to find statuses and hashtags too. Set Resolve to fetch an unknown remote user@domain via WebFinger. Page with Offset. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: searchAccountsOutputSchema, + props: { + q: Property.ShortText({ + displayName: 'Query', + description: 'Text to search for, for example a name, username or user@domain.', + required: true, + }), + limit: mastodonProps.limit({ noun: 'accounts', defaultLimit: 40, maxLimit: 80 }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of results to skip, for paging. Defaults to 0.', + required: false, + }), + resolve: mastodonProps.optionalBoolean({ + displayName: 'Resolve Remote Accounts', + description: 'Look up unknown remote accounts via WebFinger (slower). Leave empty for the server default (no).', + }), + following: mastodonProps.optionalBoolean({ + displayName: 'Only Accounts I Follow', + description: 'Limit results to accounts the connected account follows. Leave empty for no restriction.', + }), + }, + async run(context) { + const { q, limit, offset, resolve, following } = context.propsValue; + const accounts = await mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v1/accounts/search', + operation: 'Search Accounts', + scope: 'read:accounts', + query: { q, limit, offset, resolve, following }, + }); + return { accounts, count: accounts.length }; + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/search.ts b/packages/pieces/community/mastodon/src/lib/actions/search.ts new file mode 100644 index 000000000000..dfa3256e7bbe --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/search.ts @@ -0,0 +1,105 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonProps } from '../common/client'; + +const URL_PATTERN = /^https?:\/\//i; +const HANDLE_PATTERN = /^@?[^@\s]+@[^@\s]+\.[^@\s]+$/; +import { searchOutputSchema } from '../output-schemas'; + +export const search = createAction({ + auth: mastodonAuth, + name: 'search', + classification: 'SEARCH', + displayName: 'Search', + description: 'Search for accounts, statuses and hashtags.', + audience: 'ai', + aiMetadata: { + description: + 'Searches accounts, statuses and hashtags on the connected server. It is also how to import remote content: a status URL or a user@domain handle with resolve=true fetches it from the other server and returns its local ID for other actions; resolve is turned on automatically for such queries unless you set it. Full-text status search depends on the server and may only match your own posts. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: searchOutputSchema, + props: { + q: Property.ShortText({ + displayName: 'Query', + description: + 'Search text, a hashtag, a handle such as Gargron@mastodon.social, or a status or profile URL such as https://mastodon.social/@Gargron/109372843234737004.', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Result Type', + description: 'Only return one kind of result. Leave empty for all three.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Accounts', value: 'accounts' }, + { label: 'Statuses', value: 'statuses' }, + { label: 'Hashtags', value: 'hashtags' }, + ], + }, + }), + resolve: mastodonProps.optionalBoolean({ + displayName: 'Resolve Remote Content', + description: + 'Fetch unknown remote accounts or URLs from their server. Leave empty to resolve automatically when the query is a URL or user@domain handle.', + }), + following: mastodonProps.optionalBoolean({ + displayName: 'Only Accounts I Follow', + description: 'Only include accounts the connected account follows. Leave empty for no restriction.', + }), + account_id: Property.ShortText({ + displayName: 'Statuses By Account ID', + description: 'Only return statuses authored by this account (local account ID).', + required: false, + }), + exclude_unreviewed: mastodonProps.optionalBoolean({ + displayName: 'Exclude Unreviewed Hashtags', + description: 'Leave out hashtags not yet reviewed by moderators. Leave empty for the default (no).', + }), + limit: mastodonProps.limit({ noun: 'results per type', defaultLimit: 20, maxLimit: 40 }), + offset: Property.Number({ + displayName: 'Offset', + description: 'Number of results to skip, for paging. Only applies when Result Type is set.', + required: false, + }), + max_id: Property.ShortText({ + displayName: 'Max ID', + description: 'Only return results with an ID lower than this.', + required: false, + }), + min_id: Property.ShortText({ + displayName: 'Min ID', + description: 'Only return results with an ID immediately higher than this.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const query = props.q.trim(); + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/api/v2/search', + operation: 'Search', + scope: 'read:search', + query: { + q: query, + type: props.type, + resolve: props.resolve ?? (shouldAutoResolve({ query }) ? true : undefined), + following: props.following, + account_id: props.account_id, + exclude_unreviewed: props.exclude_unreviewed, + limit: props.limit, + offset: props.offset, + max_id: props.max_id, + min_id: props.min_id, + }, + }); + }, +}); + +function shouldAutoResolve({ query }: { query: string }): boolean { + return URL_PATTERN.test(query) || HANDLE_PATTERN.test(query); +} diff --git a/packages/pieces/community/mastodon/src/lib/actions/unblock-account.ts b/packages/pieces/community/mastodon/src/lib/actions/unblock-account.ts new file mode 100644 index 000000000000..99f702acced0 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unblock-account.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const unblockAccount = createAction({ + auth: mastodonAuth, + name: 'unblock_account', + classification: 'WRITE', + displayName: 'Unblock Account', + description: 'Unblock an account.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a block on an account. Follows removed by the block are not restored. Safe to retry. Returns the updated relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/unblock`, + operation: 'Unblock Account', + scope: 'write:blocks', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unbookmark-status.ts b/packages/pieces/community/mastodon/src/lib/actions/unbookmark-status.ts new file mode 100644 index 000000000000..0249db24ae05 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unbookmark-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const unbookmarkStatus = createAction({ + auth: mastodonAuth, + name: 'unbookmark_status', + classification: 'WRITE', + displayName: 'Remove Bookmark', + description: 'Remove a status from your bookmarks.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a status from the connected account\'s bookmarks. Safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/unbookmark`, + operation: 'Remove Bookmark', + scope: 'write:bookmarks', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unboost-status.ts b/packages/pieces/community/mastodon/src/lib/actions/unboost-status.ts new file mode 100644 index 000000000000..efa13823cb2d --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unboost-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const unboostStatus = createAction({ + auth: mastodonAuth, + name: 'unboost_status', + classification: 'WRITE', + displayName: 'Unboost Status', + description: 'Undo a boost (reblog) of a status.', + audience: 'ai', + aiMetadata: { + description: + 'Removes the connected account\'s boost of a status. Pass the original status ID, not the ID of the boost itself. Safe to retry; counts against the rate limit of 30 deletes and unboosts per 30 minutes. Returns the original status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/unreblog`, + operation: 'Unboost Status', + scope: 'write:statuses', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unfavourite-status.ts b/packages/pieces/community/mastodon/src/lib/actions/unfavourite-status.ts new file mode 100644 index 000000000000..1ed4b60c4be0 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unfavourite-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const unfavouriteStatus = createAction({ + auth: mastodonAuth, + name: 'unfavourite_status', + classification: 'WRITE', + displayName: 'Unfavourite Status', + description: 'Remove a status from your favourites.', + audience: 'ai', + aiMetadata: { + description: + 'Removes the connected account\'s favourite from a status. Safe to retry; unfavouriting a status that is not favourited changes nothing. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/unfavourite`, + operation: 'Unfavourite Status', + scope: 'write:favourites', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unfollow-account.ts b/packages/pieces/community/mastodon/src/lib/actions/unfollow-account.ts new file mode 100644 index 000000000000..071809a6a1d2 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unfollow-account.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const unfollowAccount = createAction({ + auth: mastodonAuth, + name: 'unfollow_account', + classification: 'WRITE', + displayName: 'Unfollow Account', + description: 'Stop following an account.', + audience: 'ai', + aiMetadata: { + description: + 'Unfollows an account (or withdraws a pending follow request) as the connected account. Safe to retry. Returns the updated relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/unfollow`, + operation: 'Unfollow Account', + scope: 'write:follows', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unfollow-tag.ts b/packages/pieces/community/mastodon/src/lib/actions/unfollow-tag.ts new file mode 100644 index 000000000000..b46a75689535 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unfollow-tag.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { tagOutputSchema } from '../output-schemas'; + +export const unfollowTag = createAction({ + auth: mastodonAuth, + name: 'unfollow_tag', + classification: 'WRITE', + displayName: 'Unfollow Hashtag', + description: 'Stop following a hashtag.', + audience: 'ai', + aiMetadata: { + description: + 'Stops following a hashtag. Requires Mastodon 4.0 or later. Safe to retry. Returns the hashtag.', + idempotent: true, + }, + outputSchema: tagOutputSchema, + props: { + tag_name: Property.ShortText({ + displayName: 'Hashtag', + description: + 'Hashtag name without the # sign, for example mastodon. A leading # is removed automatically.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/tags/${encodeURIComponent(context.propsValue.tag_name.replace(/^#/, ''))}/unfollow`, + operation: 'Unfollow Hashtag', + scope: 'write:follows', + minVersion: '4.0.0', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unmute-account.ts b/packages/pieces/community/mastodon/src/lib/actions/unmute-account.ts new file mode 100644 index 000000000000..61f16333741c --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unmute-account.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { relationshipOutputSchema } from '../output-schemas'; + +export const unmuteAccount = createAction({ + auth: mastodonAuth, + name: 'unmute_account', + classification: 'WRITE', + displayName: 'Unmute Account', + description: 'Unmute an account.', + audience: 'ai', + aiMetadata: { + description: + 'Removes a mute on an account so its posts and notifications show again. Safe to retry. Returns the updated relationship.', + idempotent: true, + }, + outputSchema: relationshipOutputSchema, + props: { + account_id: Property.ShortText({ + displayName: 'Account ID', + description: + 'Local ID of the account on this server, for example 109302436954721982. Obtain it from Lookup Account, Search Accounts, Get Account or Search (use resolve for a remote user@domain).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/accounts/${encodeURIComponent(context.propsValue.account_id)}/unmute`, + operation: 'Unmute Account', + scope: 'write:mutes', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unmute-conversation.ts b/packages/pieces/community/mastodon/src/lib/actions/unmute-conversation.ts new file mode 100644 index 000000000000..0e5ec99ec257 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unmute-conversation.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const unmuteConversation = createAction({ + auth: mastodonAuth, + name: 'unmute_conversation', + classification: 'WRITE', + displayName: 'Unmute Conversation', + description: 'Resume notifications for a muted thread.', + audience: 'ai', + aiMetadata: { + description: + 'Unmutes notifications for the conversation thread a status belongs to. Safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of the status on this server, for example 109372843234737004. Obtain it from a timeline, Get Status or Search (use resolve for a status URL from another server).', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/unmute`, + operation: 'Unmute Conversation', + scope: 'write:mutes', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/unpin-status.ts b/packages/pieces/community/mastodon/src/lib/actions/unpin-status.ts new file mode 100644 index 000000000000..04d6f329f23e --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/unpin-status.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { statusOutputSchema } from '../output-schemas'; + +export const unpinStatus = createAction({ + auth: mastodonAuth, + name: 'unpin_status', + classification: 'WRITE', + displayName: 'Unpin Status from Profile', + description: 'Remove a pinned status from your profile.', + audience: 'ai', + aiMetadata: { + description: + 'Unpins one of the connected account\'s statuses from its profile. Safe to retry. Returns the updated status.', + idempotent: true, + }, + outputSchema: statusOutputSchema, + props: { + status_id: Property.ShortText({ + displayName: 'Status ID', + description: + 'Local ID of your own status. Obtain it from Create Status, Get Status or List Account Statuses.', + required: true, + }), + }, + async run(context) { + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/statuses/${encodeURIComponent(context.propsValue.status_id)}/unpin`, + operation: 'Unpin Status from Profile', + scope: 'write:accounts', + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/update-filter.ts b/packages/pieces/community/mastodon/src/lib/actions/update-filter.ts new file mode 100644 index 000000000000..25ece72df622 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/update-filter.ts @@ -0,0 +1,88 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonUtils } from '../common/client'; + +const CONTEXT_OPTIONS = [ + { label: 'Home and lists', value: 'home' }, + { label: 'Notifications', value: 'notifications' }, + { label: 'Public timelines', value: 'public' }, + { label: 'Conversations (threads)', value: 'thread' }, + { label: 'Profiles', value: 'account' }, +]; + +const FILTER_ACTION_OPTIONS = [ + { label: 'Warn (hide behind a warning)', value: 'warn' }, + { label: 'Hide completely', value: 'hide' }, + { label: 'Blur media (Mastodon 4.4+)', value: 'blur' }, +]; +import { filterOutputSchema } from '../output-schemas'; + +export const updateFilter = createAction({ + auth: mastodonAuth, + name: 'update_filter', + classification: 'WRITE', + displayName: 'Update Filter', + description: 'Change the title, contexts, action or expiry of a content filter.', + audience: 'ai', + aiMetadata: { + description: + 'Updates one of the connected account\'s content filters; only the fields you set are sent. Apply In replaces the whole context list when set. Keywords are not changed here. Setting the same values again converges, so it is safe to retry. Requires Mastodon 4.0 or later.', + idempotent: true, + }, + outputSchema: filterOutputSchema, + props: { + filter_id: Property.ShortText({ + displayName: 'Filter ID', + description: 'ID of the filter. Obtain it from List Filters or Create Filter.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'New name of the filter. Leave empty to keep the current title.', + required: false, + }), + context: Property.StaticMultiSelectDropdown({ + displayName: 'Apply In', + description: 'Replaces where the filter applies. Leave empty to keep the current contexts.', + required: false, + options: { disabled: false, options: CONTEXT_OPTIONS }, + }), + filter_action: Property.StaticDropdown({ + displayName: 'Action', + description: 'What happens to matching posts. Leave empty to keep the current action.', + required: false, + options: { disabled: false, options: FILTER_ACTION_OPTIONS }, + }), + expires_in: Property.Number({ + displayName: 'Expires In (seconds)', + description: 'New expiry, in seconds from now. Leave empty to keep the current expiry.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = { + ...(mastodonUtils.hasValue(props.title) ? { title: props.title } : {}), + ...(props.context !== undefined && props.context.length > 0 + ? { context: props.context } + : {}), + ...(mastodonUtils.hasValue(props.filter_action) ? { filter_action: props.filter_action } : {}), + ...(props.expires_in !== undefined && props.expires_in !== null + ? { expires_in: props.expires_in } + : {}), + }; + if (Object.keys(body).length === 0) { + throw new Error('Set at least one field to update.'); + } + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/api/v2/filters/${encodeURIComponent(props.filter_id)}`, + operation: 'Update Filter', + scope: 'write:filters', + minVersion: '4.0.0', + body, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/update-list.ts b/packages/pieces/community/mastodon/src/lib/actions/update-list.ts new file mode 100644 index 000000000000..8e500a9a60c5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/update-list.ts @@ -0,0 +1,72 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; + +const REPLIES_POLICY_OPTIONS = [ + { label: 'Any followed user', value: 'followed' }, + { label: 'Members of the list', value: 'list' }, + { label: 'No one', value: 'none' }, +]; +import { listOutputSchema } from '../output-schemas'; + +export const updateList = createAction({ + auth: mastodonAuth, + name: 'update_list', + classification: 'WRITE', + displayName: 'Update List', + description: 'Rename a list or change its settings.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the title, replies policy or exclusive setting of one of the connected account\'s lists; only the fields you set change. Use Add Accounts to List or Remove Accounts from List to change members. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: listOutputSchema, + props: { + list_id: Property.ShortText({ + displayName: 'List ID', + description: 'ID of one of your lists. Obtain it from List Lists or Create List.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'New name of the list. Leave empty to keep the current title.', + required: false, + }), + replies_policy: Property.StaticDropdown({ + displayName: 'Show Replies To', + description: 'Which replies appear in the list timeline. Leave empty to keep the current setting.', + required: false, + options: { disabled: false, options: REPLIES_POLICY_OPTIONS }, + }), + exclusive: mastodonProps.optionalBoolean({ + displayName: 'Hide Members From Home Timeline', + description: 'Whether posts from list members are removed from the home timeline. Leave empty to keep the current setting.', + }), + }, + async run(context) { + const { list_id, title, replies_policy, exclusive } = context.propsValue; + const body = { + ...(mastodonUtils.hasValue(title) ? { title } : {}), + ...(mastodonUtils.hasValue(replies_policy) ? { replies_policy } : {}), + ...(exclusive !== undefined ? { exclusive } : {}), + }; + if (Object.keys(body).length === 0) { + throw new Error('Set Title, Show Replies To or Hide Members From Home Timeline; there is nothing to update.'); + } + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/api/v1/lists/${encodeURIComponent(list_id)}`, + operation: 'Update List', + scope: 'write:lists', + body, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/update-media.ts b/packages/pieces/community/mastodon/src/lib/actions/update-media.ts new file mode 100644 index 000000000000..7753535b755b --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/update-media.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient, mastodonUtils } from '../common/client'; +import { mediaAttachmentOutputSchema } from '../output-schemas'; + +export const updateMedia = createAction({ + auth: mastodonAuth, + name: 'update_media', + classification: 'WRITE', + displayName: 'Update Media', + description: 'Change the alt text or focal point of an uploaded media file.', + audience: 'ai', + aiMetadata: { + description: + 'Updates the alt text and/or focal point of an uploaded media attachment before it is posted; only the fields you set are sent. Only works for media not yet attached to a status; to change media on a published post use Edit Status. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: mediaAttachmentOutputSchema, + props: { + media_id: Property.ShortText({ + displayName: 'Media ID', + description: 'ID of the unattached media. Obtain it from Upload Media.', + required: true, + }), + description: Property.LongText({ + displayName: 'Alt Text', + description: 'New plain-text description of the media for accessibility.', + required: false, + }), + focus: Property.ShortText({ + displayName: 'Focal Point', + description: + 'New focal point as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.', + required: false, + }), + }, + async run(context) { + const { media_id, description, focus } = context.propsValue; + const body = { + ...(mastodonUtils.hasValue(description) ? { description } : {}), + ...(mastodonUtils.hasValue(focus) ? { focus } : {}), + }; + if (Object.keys(body).length === 0) { + throw new Error('Set Alt Text or Focal Point; there is nothing to update.'); + } + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/api/v1/media/${encodeURIComponent(media_id)}`, + operation: 'Update Media', + scope: 'write:media', + notFoundMessage: + 'Mastodon could not find this media. Update Media only works for media not yet attached to a status; to change media on a published post use Edit Status.', + body, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/update-my-profile.ts b/packages/pieces/community/mastodon/src/lib/actions/update-my-profile.ts new file mode 100644 index 000000000000..ce19190889a5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/update-my-profile.ts @@ -0,0 +1,97 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { + MastodonEntity, + mastodonClient, + mastodonProps, + mastodonUtils, +} from '../common/client'; +import { myAccountOutputSchema } from '../output-schemas'; + +export const updateMyProfile = createAction({ + auth: mastodonAuth, + name: 'update_my_profile', + classification: 'WRITE', + displayName: 'Update My Profile', + description: 'Update the display name, bio and settings of the connected account.', + audience: 'ai', + aiMetadata: { + description: + 'Updates the connected account\'s public profile and posting defaults; only the fields you set change, the rest stay as they are. Profile metadata fields, avatar and header are not handled here. Changes are visible to everyone who views the profile. Setting the same values again converges, so it is safe to retry.', + idempotent: true, + }, + outputSchema: myAccountOutputSchema, + props: { + display_name: Property.ShortText({ + displayName: 'Display Name', + description: 'New display name shown on the profile. Leave empty to keep the current display name.', + required: false, + }), + note: Property.LongText({ + displayName: 'Bio', + description: 'New profile bio text. Leave empty to keep the current bio.', + required: false, + }), + locked: mastodonProps.optionalBoolean({ + displayName: 'Require Follow Approval', + description: 'Whether new followers must be approved manually. Leave empty to keep the current setting.', + }), + bot: mastodonProps.optionalBoolean({ + displayName: 'Bot Account', + description: 'Whether the profile is flagged as an automated (bot) account. Leave empty to keep the current setting.', + }), + discoverable: mastodonProps.optionalBoolean({ + displayName: 'Discoverable', + description: 'Whether the account is featured in the profile directory. Leave empty to keep the current setting.', + }), + default_visibility: Property.StaticDropdown({ + displayName: 'Default Post Visibility', + description: 'Default privacy for new posts. Leave empty to keep the current setting.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Public', value: 'public' }, + { label: 'Unlisted', value: 'unlisted' }, + { label: 'Followers only', value: 'private' }, + ], + }, + }), + default_language: Property.ShortText({ + displayName: 'Default Post Language', + description: 'Default ISO 639-1 language code for new posts, for example en.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const source = { + ...(mastodonUtils.hasValue(props.default_visibility) + ? { privacy: props.default_visibility } + : {}), + ...(mastodonUtils.hasValue(props.default_language) + ? { language: props.default_language } + : {}), + }; + const body = { + ...(mastodonUtils.hasValue(props.display_name) ? { display_name: props.display_name } : {}), + ...(mastodonUtils.hasValue(props.note) ? { note: props.note } : {}), + ...(props.locked !== undefined ? { locked: props.locked } : {}), + ...(props.bot !== undefined ? { bot: props.bot } : {}), + ...(props.discoverable !== undefined ? { discoverable: props.discoverable } : {}), + ...(Object.keys(source).length > 0 ? { source } : {}), + }; + if (Object.keys(body).length === 0) { + throw new Error('Set at least one profile field to update.'); + } + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.PATCH, + path: '/api/v1/accounts/update_credentials', + operation: 'Update My Profile', + scope: 'write:accounts', + body, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/actions/upload-media.ts b/packages/pieces/community/mastodon/src/lib/actions/upload-media.ts new file mode 100644 index 000000000000..ad6ff2b08e4b --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/upload-media.ts @@ -0,0 +1,75 @@ +import FormData from 'form-data'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { mastodonClient, mastodonUtils } from '../common/client'; +import { mappedMediaOutputSchema } from '../output-schemas'; + +export const uploadMedia = createAction({ + auth: mastodonAuth, + name: 'upload_media', + classification: 'WRITE', + displayName: 'Upload Media', + description: 'Upload an image, video or audio file to attach to a status.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a media file and returns its ID to pass as Media IDs to Create Status, Schedule Status or Edit Status. Large video and audio are processed asynchronously: when processing is true the url is still null, so call Get Media until url is non-null before attaching it. Unattached uploads expire after a while. Each call uploads a new file.', + idempotent: false, + }, + outputSchema: mappedMediaOutputSchema, + props: { + file: Property.File({ + displayName: 'File', + description: 'The image, video or audio file to upload.', + required: true, + }), + description: Property.LongText({ + displayName: 'Alt Text', + description: 'Plain-text description of the media for accessibility (alt text).', + required: false, + }), + focus: Property.ShortText({ + displayName: 'Focal Point', + description: + 'Focal point for thumbnail cropping as two comma-separated numbers from -1.0 to 1.0, for example 0.0,0.5.', + required: false, + }), + }, + async run(context) { + const { file, description, focus } = context.propsValue; + const form = new FormData(); + form.append('file', file.data, file.filename); + if (mastodonUtils.hasValue(description)) { + form.append('description', String(description)); + } + if (mastodonUtils.hasValue(focus)) { + form.append('focus', String(focus)); + } + const response = await mastodonClient.sendRequest({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/api/v2/media', + operation: 'Upload Media', + scope: 'write:media', + body: form, + }); + const media = response.body; + return { + id: media.id, + type: media.type, + url: media.url ?? null, + preview_url: media.preview_url ?? null, + description: media.description ?? null, + processing: response.status === 202, + }; + }, +}); + +type MediaAttachment = { + id: string; + type: string; + url: string | null; + preview_url: string | null; + description: string | null; +}; diff --git a/packages/pieces/community/mastodon/src/lib/actions/vote-in-poll.ts b/packages/pieces/community/mastodon/src/lib/actions/vote-in-poll.ts new file mode 100644 index 000000000000..63fe656d8cb2 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/actions/vote-in-poll.ts @@ -0,0 +1,50 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { mastodonAuth } from '../..'; +import { MastodonEntity, mastodonClient } from '../common/client'; +import { pollOutputSchema } from '../output-schemas'; + +export const voteInPoll = createAction({ + auth: mastodonAuth, + name: 'vote_in_poll', + classification: 'WRITE', + displayName: 'Vote in Poll', + description: 'Cast a vote in a poll.', + audience: 'ai', + aiMetadata: { + description: + 'Casts the connected account\'s vote in a poll using 0-based option indexes (read the options with Get Poll or Get Status). Pass several indexes only when the poll allows multiple choices. A vote cannot be changed: voting again or voting in an expired poll is rejected, so do not retry. Returns the updated poll.', + idempotent: false, + }, + outputSchema: pollOutputSchema, + props: { + poll_id: Property.ShortText({ + displayName: 'Poll ID', + description: 'ID of the poll (the poll.id field of a status). Obtain it from Get Status or a timeline.', + required: true, + }), + choices: Property.Array({ + displayName: 'Choices', + description: + '0-based index of each option to vote for, one per item; for example 0 for the first option.', + required: true, + }), + }, + async run(context) { + const { poll_id, choices } = context.propsValue; + const indexes = choices + .map((choice) => (typeof choice === 'number' ? choice : Number(String(choice).trim()))) + .filter((choice) => Number.isInteger(choice) && choice >= 0); + if (indexes.length === 0 || indexes.length !== choices.length) { + throw new Error('Choices must be 0-based option indexes such as 0 or 1.'); + } + return mastodonClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/api/v1/polls/${encodeURIComponent(poll_id)}/votes`, + operation: 'Vote in Poll', + scope: 'write:statuses', + body: { choices: indexes }, + }); + }, +}); diff --git a/packages/pieces/community/mastodon/src/lib/common/client.ts b/packages/pieces/community/mastodon/src/lib/common/client.ts new file mode 100644 index 000000000000..4caf24d984a5 --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/common/client.ts @@ -0,0 +1,534 @@ +import { + AuthenticationType, + httpClient, + HttpError, + HttpHeaders, + HttpMethod, + HttpResponse, +} from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; + +const RATE_LIMIT_GUIDANCE = + 'Mastodon allows about 300 requests per 5 minutes per account by default (media uploads 30 per 30 minutes; status deletes and unboosts 30 per 30 minutes combined), and instances can tune these limits. Wait a few minutes before retrying.'; + +const SCOPE_GUIDANCE = + 'In Mastodon, open Preferences > Development, select the application, tick the top-level read and write scopes and save. Saving regenerates the access token, so paste the new token into this Activepieces connection.'; + +const MIN_SCHEDULE_AHEAD_MS = 5 * 60 * 1000; +const SCHEDULE_SAFETY_MARGIN_MS = 60 * 1000; + +const NOTIFICATION_TYPE_OPTIONS = [ + { label: 'Mention', value: 'mention' }, + { label: 'New post from an account you enabled notifications for', value: 'status' }, + { label: 'Boost', value: 'reblog' }, + { label: 'Follow', value: 'follow' }, + { label: 'Follow request', value: 'follow_request' }, + { label: 'Favourite', value: 'favourite' }, + { label: 'Poll ended', value: 'poll' }, + { label: 'Boosted post edited', value: 'update' }, + { label: 'Quote', value: 'quote' }, + { label: 'Quoted post edited', value: 'quoted_update' }, + { label: 'Relationships severed', value: 'severed_relationships' }, + { label: 'Moderation warning', value: 'moderation_warning' }, + { label: 'Admin: new sign-up', value: 'admin.sign_up' }, + { label: 'Admin: new report', value: 'admin.report' }, +]; + +function getBaseUrl(auth: MastodonConnection): string { + return auth.base_url.replace(/\/$/, ''); +} + +function buildQueryString(query: MastodonQuery | undefined): string { + if (query === undefined) { + return ''; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + params.append(`${key}[]`, item); + } + continue; + } + params.append(key, String(value)); + } + const queryString = params.toString(); + return queryString === '' ? '' : `?${queryString}`; +} + +function extractVendorError(body: unknown): string | undefined { + if (typeof body === 'string' && body.trim() !== '') { + return body; + } + if (typeof body === 'object' && body !== null) { + if ('error' in body && typeof body.error === 'string') { + return body.error; + } + if ('error_description' in body && typeof body.error_description === 'string') { + return body.error_description; + } + } + return undefined; +} + +function describeFailure({ + status, + body, + operation, + scope, + minVersion, + notFoundMessage, +}: { + status: number; + body: unknown; + operation: string; + scope?: string; + minVersion?: string; + notFoundMessage?: string; +}): string { + const vendorError = extractVendorError(body); + const vendorSuffix = vendorError === undefined ? '' : ` Mastodon said: "${vendorError}".`; + switch (status) { + case 401: + return `Mastodon rejected the access token while running ${operation}: it is invalid or has been revoked. Create a new access token under Preferences > Development and update the connection.${vendorSuffix}`; + case 403: { + const isScopeError = + vendorError === undefined || /authorized scopes|scope/i.test(vendorError); + if (isScopeError && scope !== undefined) { + return `Mastodon refused ${operation} because the connection's access token lacks the "${scope}" scope.${vendorSuffix} ${SCOPE_GUIDANCE}`; + } + return `Mastodon refused ${operation}: the connected account is not allowed to perform this action on this resource.${vendorSuffix}`; + } + case 404: { + if (notFoundMessage !== undefined) { + return `${notFoundMessage}${vendorSuffix}`; + } + const versionHint = + minVersion === undefined + ? '' + : ` This endpoint requires Mastodon ${minVersion} or later; use Get Instance Info to check the server version.`; + return `Mastodon could not find the resource for ${operation}. It may not exist, may not be visible to the connected account, or the server may not support this endpoint.${versionHint}${vendorSuffix}`; + } + case 422: + return `Mastodon rejected ${operation}: ${vendorError ?? 'the request was invalid'}.`; + case 429: + return `Mastodon rate limit reached while running ${operation}. ${RATE_LIMIT_GUIDANCE}`; + default: + return `Mastodon returned HTTP ${status} for ${operation}.${vendorSuffix}`; + } +} + +async function sendRequest({ + auth, + method, + path, + query, + body, + headers, + operation, + scope, + minVersion, + notFoundMessage, +}: MastodonRequestParams): Promise> { + try { + return await httpClient.sendRequest({ + method, + url: `${getBaseUrl(auth)}${path}${buildQueryString(query)}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + headers, + body, + }); + } catch (error) { + if (error instanceof HttpError) { + const status = error.response.status; + throw new MastodonApiError({ + status, + message: describeFailure({ + status, + body: error.response.body, + operation, + scope, + minVersion, + notFoundMessage, + }), + }); + } + throw error; + } +} + +async function request(params: MastodonRequestParams): Promise { + const response = await sendRequest(params); + return response.body; +} + +function readLinkHeader(headers: HttpHeaders | undefined): string | undefined { + if (headers === undefined) { + return undefined; + } + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== 'link' || value === undefined) { + continue; + } + return Array.isArray(value) ? value.join(', ') : value; + } + return undefined; +} + +function parseLinkHeader(headers: HttpHeaders | undefined): MastodonCursors { + const cursors: MastodonCursors = { + next_max_id: null, + prev_min_id: null, + prev_since_id: null, + }; + const linkHeader = readLinkHeader(headers); + if (linkHeader === undefined) { + return cursors; + } + const linkPattern = /<([^>]+)>\s*;\s*rel="([^"]+)"/g; + for (const match of linkHeader.matchAll(linkPattern)) { + const url = safeParseUrl(match[1]); + if (url === null) { + continue; + } + const rel = match[2]; + if (rel === 'next') { + cursors.next_max_id = url.searchParams.get('max_id'); + } + if (rel === 'prev') { + cursors.prev_min_id = url.searchParams.get('min_id'); + cursors.prev_since_id = url.searchParams.get('since_id'); + } + } + return cursors; +} + +function safeParseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +async function requestPage(params: MastodonRequestParams): Promise> { + const response = await sendRequest(params); + const items = Array.isArray(response.body) ? response.body : []; + return { + items, + count: items.length, + ...parseLinkHeader(response.headers), + }; +} + +function toStringArray(value: unknown[] | undefined): string[] | undefined { + if (value === undefined) { + return undefined; + } + const items = value + .filter((item) => typeof item === 'string' || typeof item === 'number') + .map((item) => String(item).trim()) + .filter((item) => item !== ''); + return items.length === 0 ? undefined : items; +} + +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +function optionalBooleanProp({ + displayName, + description, +}: { + displayName: string; + description: string; +}) { + return Property.StaticDropdown({ + displayName, + description, + required: false, + options: { + disabled: false, + options: [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ], + }, + }); +} + +function limitProp({ + noun, + defaultLimit, + maxLimit, +}: { + noun: string; + defaultLimit: number; + maxLimit: number; +}) { + return Property.Number({ + displayName: 'Limit', + description: `Maximum number of ${noun} to return in this page. Defaults to ${defaultLimit}, maximum ${maxLimit}.`, + required: false, + }); +} + +function notificationTypesProp({ + displayName, + description, +}: { + displayName: string; + description: string; +}) { + return Property.StaticMultiSelectDropdown({ + displayName, + description, + required: false, + options: { disabled: false, options: NOTIFICATION_TYPE_OPTIONS }, + }); +} + +function maxIdProp() { + return Property.ShortText({ + displayName: 'Max ID (older page)', + description: + 'Cursor for the next (older) page. Pass the `next_max_id` value from the previous page of this same action. Leave empty for the newest page.', + required: false, + }); +} + +function sinceIdProp() { + return Property.ShortText({ + displayName: 'Since ID (newer results)', + description: + 'Cursor for newer results. Pass the `prev_since_id` value from a previous page of this same action to page back towards newer items.', + required: false, + }); +} + +function minIdProp() { + return Property.ShortText({ + displayName: 'Min ID (newer page)', + description: + 'Cursor for the page immediately newer than a previous page. Pass the `prev_min_id` value from a previous page of this same action.', + required: false, + }); +} + +function assertScheduledAt(scheduledAt: string): string { + const scheduledTime = new Date(scheduledAt).getTime(); + if (Number.isNaN(scheduledTime)) { + throw new Error( + `Scheduled At must be an ISO 8601 date-time such as 2026-10-01T09:30:00Z, got "${scheduledAt}".` + ); + } + if (scheduledTime - Date.now() < MIN_SCHEDULE_AHEAD_MS + SCHEDULE_SAFETY_MARGIN_MS) { + throw new Error( + 'Scheduled At must be at least 6 minutes in the future; Mastodon rejects anything under 5 minutes, and the extra minute absorbs clock skew.' + ); + } + return new Date(scheduledTime).toISOString(); +} + +function buildComposeBody({ + status, + visibility, + inReplyToId, + spoilerText, + sensitive, + language, + mediaIds, + pollOptions, + pollExpiresIn, + pollMultiple, +}: ComposeInput): Record { + const hasMedia = mediaIds !== undefined && mediaIds.length > 0; + const hasPoll = pollOptions !== undefined && pollOptions.length > 0; + const hasText = status !== undefined && status.trim() !== ''; + if (!hasText && !hasMedia) { + throw new Error('Provide the status text, or at least one Media ID to post media without text.'); + } + if (hasMedia && hasPoll) { + throw new Error( + 'A status cannot have both media and a poll. Remove the Media IDs or the Poll Options.' + ); + } + if (hasPoll && (pollExpiresIn === undefined || pollExpiresIn === null)) { + throw new Error('Poll Duration (seconds) is required when Poll Options are provided.'); + } + const hasPollSettings = + (pollExpiresIn !== undefined && pollExpiresIn !== null) || pollMultiple !== undefined; + if (!hasPoll && hasPollSettings) { + throw new Error('Poll Duration and Allow Multiple Choices only apply when Poll Options are provided.'); + } + return { + ...(hasText ? { status } : {}), + ...(hasValue(visibility) ? { visibility } : {}), + ...(hasValue(inReplyToId) ? { in_reply_to_id: inReplyToId } : {}), + ...(hasValue(spoilerText) ? { spoiler_text: spoilerText } : {}), + ...(sensitive !== undefined ? { sensitive } : {}), + ...(hasValue(language) ? { language } : {}), + ...(hasMedia ? { media_ids: mediaIds } : {}), + ...(hasPoll + ? { + poll: { + options: pollOptions, + expires_in: pollExpiresIn, + ...(pollMultiple !== undefined ? { multiple: pollMultiple } : {}), + }, + } + : {}), + }; +} + +function composeProps() { + return { + status: Property.LongText({ + displayName: 'Status Text', + description: + 'The text of the post. Mentions (@user@domain), hashtags and links are parsed by Mastodon. Required unless Media IDs are provided. Use Get Instance Info to read the server character limit (500 by default).', + required: false, + }), + visibility: Property.StaticDropdown({ + displayName: 'Visibility', + description: + 'Who can see the post. Leave empty to use the account default posting privacy.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Public', value: 'public' }, + { label: 'Unlisted (public, hidden from public timelines)', value: 'unlisted' }, + { label: 'Followers only', value: 'private' }, + { label: 'Direct (mentioned accounts only)', value: 'direct' }, + ], + }, + }), + in_reply_to_id: Property.ShortText({ + displayName: 'In Reply To (Status ID)', + description: + 'Local ID of the status to reply to, for example 109372843234737004. Obtain it from Get Status, a timeline or Search (with resolve for remote URLs).', + required: false, + }), + spoiler_text: Property.ShortText({ + displayName: 'Content Warning', + description: 'Optional content warning shown before the post text.', + required: false, + }), + sensitive: optionalBooleanProp({ + displayName: 'Mark Media as Sensitive', + description: 'Whether attached media should be hidden behind a sensitive-content warning. Leave empty for the account default.', + }), + language: Property.ShortText({ + displayName: 'Language', + description: 'ISO 639-1 two-letter language code of the post, for example en or de.', + required: false, + }), + media_ids: Property.Array({ + displayName: 'Media IDs', + description: + 'IDs of media returned by Upload Media, one per item (up to the server limit, 4 by default). Cannot be combined with a poll.', + required: false, + }), + poll_options: Property.Array({ + displayName: 'Poll Options', + description: + 'Answer choices for a poll, one per item (2 to 4 on most servers). Cannot be combined with media.', + required: false, + }), + poll_expires_in: Property.Number({ + displayName: 'Poll Duration (seconds)', + description: + 'How long the poll stays open, in seconds. Mastodon accepts 300 (5 minutes) to 2629746 (1 month). Required with Poll Options.', + required: false, + }), + poll_multiple: optionalBooleanProp({ + displayName: 'Allow Multiple Choices', + description: 'Whether voters may pick more than one option. Leave empty for single choice.', + }), + }; +} + +export class MastodonApiError extends Error { + public readonly status: number; + + constructor({ status, message }: { status: number; message: string }) { + super(message); + this.name = 'MastodonApiError'; + this.status = status; + } +} + +export const mastodonClient = { sendRequest, request, requestPage }; + +export const mastodonProps = { + optionalBoolean: optionalBooleanProp, + limit: limitProp, + notificationTypes: notificationTypesProp, + maxId: maxIdProp, + sinceId: sinceIdProp, + minId: minIdProp, + compose: composeProps, +}; + +export const mastodonUtils = { + toStringArray, + hasValue, + assertScheduledAt, + buildComposeBody, +}; + +export type MastodonConnection = { + base_url: string; + access_token: string; +}; + +export type MastodonQuery = Record< + string, + string | number | boolean | string[] | undefined | null +>; + +export type MastodonRequestParams = { + auth: MastodonConnection; + method: HttpMethod; + path: string; + operation: string; + scope?: string; + minVersion?: string; + notFoundMessage?: string; + query?: MastodonQuery; + body?: unknown; + headers?: Record; +}; + +export type MastodonCursors = { + next_max_id: string | null; + prev_min_id: string | null; + prev_since_id: string | null; +}; + +export type MastodonPage = MastodonCursors & { + items: T[]; + count: number; +}; + +export type MastodonEntity = Record; + +export type ComposeInput = { + status?: string; + visibility?: string; + inReplyToId?: string; + spoilerText?: string; + sensitive?: boolean; + language?: string; + mediaIds?: string[]; + pollOptions?: string[]; + pollExpiresIn?: number | null; + pollMultiple?: boolean; +}; diff --git a/packages/pieces/community/mastodon/src/lib/output-schemas.ts b/packages/pieces/community/mastodon/src/lib/output-schemas.ts new file mode 100644 index 000000000000..a74afbe7b1bb --- /dev/null +++ b/packages/pieces/community/mastodon/src/lib/output-schemas.ts @@ -0,0 +1,647 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const accountSummaryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Account ID' }, + { key: 'username', label: 'Username' }, + { key: 'acct', label: 'Handle' }, + { key: 'display_name', label: 'Display Name' }, + { key: 'url', label: 'Profile URL', format: 'url' }, + { key: 'avatar', label: 'Avatar', format: 'image' }, + { key: 'bot', label: 'Bot', format: 'boolean' }, +]; + +const accountFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Account ID' }, + { key: 'username', label: 'Username' }, + { key: 'acct', label: 'Handle' }, + { key: 'display_name', label: 'Display Name' }, + { key: 'note', label: 'Bio', format: 'html' }, + { key: 'url', label: 'Profile URL', format: 'url' }, + { key: 'uri', label: 'ActivityPub URI', format: 'url' }, + { key: 'avatar', label: 'Avatar', format: 'image' }, + { key: 'header', label: 'Header Image', format: 'image' }, + { key: 'locked', label: 'Requires Follow Approval', format: 'boolean' }, + { key: 'bot', label: 'Bot', format: 'boolean' }, + { key: 'group', label: 'Group', format: 'boolean' }, + { key: 'discoverable', label: 'Discoverable', format: 'boolean' }, + { key: 'indexable', label: 'Indexable', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'last_status_at', label: 'Last Status Date', format: 'date' }, + { key: 'followers_count', label: 'Followers', format: 'number' }, + { key: 'following_count', label: 'Following', format: 'number' }, + { key: 'statuses_count', label: 'Statuses', format: 'number' }, + { + key: 'fields', + label: 'Profile Fields', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'value', label: 'Value', format: 'html' }, + { key: 'verified_at', label: 'Verified At', format: 'datetime' }, + ], + }, +]; + +const myAccountFields: OutputSchema['fields'] = [ + ...accountFields, + { + key: 'source', + label: 'Posting Defaults', + children: [ + { key: 'privacy', label: 'Default Visibility' }, + { key: 'sensitive', label: 'Mark Media Sensitive by Default', format: 'boolean' }, + { key: 'language', label: 'Default Language' }, + { key: 'note', label: 'Bio (Plain Text)' }, + { key: 'follow_requests_count', label: 'Pending Follow Requests', format: 'number' }, + ], + }, +]; + +const mediaAttachmentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Media ID' }, + { key: 'type', label: 'Type' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'preview_url', label: 'Preview', format: 'image' }, + { key: 'remote_url', label: 'Remote URL', format: 'url' }, + { key: 'description', label: 'Alt Text' }, + { + key: 'meta', + label: 'Metadata', + children: [ + { + key: 'original', + label: 'Original', + children: [ + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + ], + }, + { + key: 'focus', + label: 'Focal Point', + children: [ + { key: 'x', label: 'X', format: 'number' }, + { key: 'y', label: 'Y', format: 'number' }, + ], + }, + ], + }, +]; + +const mappedMediaFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Media ID' }, + { key: 'type', label: 'Type' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'preview_url', label: 'Preview', format: 'image' }, + { key: 'description', label: 'Alt Text' }, + { key: 'processing', label: 'Still Processing', format: 'boolean' }, +]; + +const pollFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Poll ID' }, + { key: 'expires_at', label: 'Expires At', format: 'datetime' }, + { key: 'expired', label: 'Expired', format: 'boolean' }, + { key: 'multiple', label: 'Multiple Choice', format: 'boolean' }, + { key: 'votes_count', label: 'Votes', format: 'number' }, + { key: 'voters_count', label: 'Voters', format: 'number' }, + { key: 'voted', label: 'Voted', format: 'boolean' }, + { key: 'own_votes', label: 'Own Votes (Option Indexes)' }, + { + key: 'options', + label: 'Options', + labelKey: 'title', + listItems: [ + { key: 'title', label: 'Title' }, + { key: 'votes_count', label: 'Votes', format: 'number' }, + ], + }, +]; + +const previewCardFields: OutputSchema['fields'] = [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'type', label: 'Type' }, + { key: 'language', label: 'Language' }, + { key: 'provider_name', label: 'Provider' }, + { key: 'author_name', label: 'Author' }, + { key: 'image', label: 'Image', format: 'image' }, + { key: 'published_at', label: 'Published At', format: 'datetime' }, +]; + +const usageHistoryFields: OutputSchema['fields'] = [ + { + key: 'history', + label: 'Daily Usage', + labelKey: 'day', + listItems: [ + { key: 'day', label: 'Day (Unix Time)' }, + { key: 'uses', label: 'Uses' }, + { key: 'accounts', label: 'Accounts' }, + ], + }, +]; + +const statusCoreFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Status ID' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'uri', label: 'ActivityPub URI', format: 'url' }, + { key: 'content', label: 'Content', format: 'html' }, + { key: 'spoiler_text', label: 'Content Warning' }, + { key: 'sensitive', label: 'Sensitive', format: 'boolean' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'language', label: 'Language' }, + { key: 'in_reply_to_id', label: 'In Reply To Status ID' }, + { key: 'in_reply_to_account_id', label: 'In Reply To Account ID' }, + { key: 'replies_count', label: 'Replies', format: 'number' }, + { key: 'reblogs_count', label: 'Boosts', format: 'number' }, + { key: 'favourites_count', label: 'Favourites', format: 'number' }, + { key: 'edited_at', label: 'Edited At', format: 'datetime' }, + { key: 'account', label: 'Author', children: accountSummaryFields }, + { + key: 'media_attachments', + label: 'Media Attachments', + labelKey: 'type', + listItems: mediaAttachmentFields, + }, + { + key: 'mentions', + label: 'Mentions', + labelKey: 'acct', + listItems: [ + { key: 'id', label: 'Account ID' }, + { key: 'username', label: 'Username' }, + { key: 'acct', label: 'Handle' }, + { key: 'url', label: 'Profile URL', format: 'url' }, + ], + }, + { + key: 'tags', + label: 'Hashtags', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'url', label: 'URL', format: 'url' }, + ], + }, + { key: 'poll', label: 'Poll', children: pollFields }, + { key: 'card', label: 'Link Preview', children: previewCardFields }, +]; + +const statusFields: OutputSchema['fields'] = [ + ...statusCoreFields, + { key: 'favourited', label: 'Favourited by Me', format: 'boolean' }, + { key: 'reblogged', label: 'Boosted by Me', format: 'boolean' }, + { key: 'bookmarked', label: 'Bookmarked by Me', format: 'boolean' }, + { key: 'muted', label: 'Conversation Muted', format: 'boolean' }, + { key: 'pinned', label: 'Pinned to Profile', format: 'boolean' }, + { key: 'reblog', label: 'Boosted Status', children: statusCoreFields }, + { + key: 'application', + label: 'Posted With', + children: [ + { key: 'name', label: 'App Name' }, + { key: 'website', label: 'App Website', format: 'url' }, + ], + }, +]; + +const deletedStatusFields: OutputSchema['fields'] = [ + ...statusFields.filter((field) => field.key !== 'content'), + { key: 'text', label: 'Source Text (for Redraft)' }, +]; + +const tagFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Tag ID' }, + { key: 'name', label: 'Name' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'following', label: 'Followed by Me', format: 'boolean' }, + { key: 'featuring', label: 'Featured on Profile', format: 'boolean' }, + ...usageHistoryFields, +]; + +const trendingLinkFields: OutputSchema['fields'] = [...previewCardFields, ...usageHistoryFields]; + +const listFields: OutputSchema['fields'] = [ + { key: 'id', label: 'List ID' }, + { key: 'title', label: 'Title' }, + { key: 'replies_policy', label: 'Replies Policy' }, + { key: 'exclusive', label: 'Exclusive', format: 'boolean' }, +]; + +const filterFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Filter ID' }, + { key: 'title', label: 'Title' }, + { key: 'context', label: 'Contexts' }, + { key: 'filter_action', label: 'Action' }, + { key: 'expires_at', label: 'Expires At', format: 'datetime' }, + { + key: 'keywords', + label: 'Keywords', + labelKey: 'keyword', + listItems: [ + { key: 'id', label: 'Keyword ID' }, + { key: 'keyword', label: 'Keyword' }, + { key: 'whole_word', label: 'Whole Word', format: 'boolean' }, + ], + }, +]; + +const scheduledStatusFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Scheduled Status ID' }, + { key: 'scheduled_at', label: 'Scheduled At', format: 'datetime' }, + { + key: 'params', + label: 'Status Parameters', + children: [ + { key: 'text', label: 'Text' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'spoiler_text', label: 'Content Warning' }, + { key: 'sensitive', label: 'Sensitive', format: 'boolean' }, + { key: 'language', label: 'Language' }, + { key: 'in_reply_to_id', label: 'In Reply To Status ID' }, + { key: 'media_ids', label: 'Media IDs' }, + ], + }, + { + key: 'media_attachments', + label: 'Media Attachments', + labelKey: 'type', + listItems: mediaAttachmentFields, + }, +]; + +const instanceFields: OutputSchema['fields'] = [ + { key: 'domain', label: 'Domain' }, + { key: 'title', label: 'Title' }, + { key: 'version', label: 'Mastodon Version' }, + { key: 'description', label: 'Description' }, + { key: 'source_url', label: 'Source Code URL', format: 'url' }, + { key: 'languages', label: 'Languages' }, + { + key: 'usage', + label: 'Usage', + children: [ + { + key: 'users', + label: 'Users', + children: [{ key: 'active_month', label: 'Monthly Active Users', format: 'number' }], + }, + ], + }, + { + key: 'thumbnail', + label: 'Thumbnail', + children: [{ key: 'url', label: 'URL', format: 'image' }], + }, + { + key: 'configuration', + label: 'Configuration', + children: [ + { + key: 'statuses', + label: 'Statuses', + children: [ + { key: 'max_characters', label: 'Max Characters', format: 'number' }, + { key: 'max_media_attachments', label: 'Max Media Attachments', format: 'number' }, + { key: 'characters_reserved_per_url', label: 'Characters per URL', format: 'number' }, + ], + }, + { + key: 'media_attachments', + label: 'Media Attachments', + children: [ + { key: 'image_size_limit', label: 'Image Size Limit', format: 'filesize' }, + { key: 'video_size_limit', label: 'Video Size Limit', format: 'filesize' }, + { key: 'description_limit', label: 'Alt Text Limit', format: 'number' }, + { key: 'supported_mime_types', label: 'Supported MIME Types' }, + ], + }, + { + key: 'polls', + label: 'Polls', + children: [ + { key: 'max_options', label: 'Max Options', format: 'number' }, + { key: 'max_characters_per_option', label: 'Max Characters per Option', format: 'number' }, + { key: 'min_expiration', label: 'Min Duration (Seconds)', format: 'number' }, + { key: 'max_expiration', label: 'Max Duration (Seconds)', format: 'number' }, + ], + }, + { + key: 'translation', + label: 'Translation', + children: [{ key: 'enabled', label: 'Enabled', format: 'boolean' }], + }, + ], + }, + { + key: 'registrations', + label: 'Registrations', + children: [ + { key: 'enabled', label: 'Open', format: 'boolean' }, + { key: 'approval_required', label: 'Approval Required', format: 'boolean' }, + { key: 'min_age', label: 'Minimum Age', format: 'number' }, + ], + }, + { + key: 'contact', + label: 'Contact', + children: [ + { key: 'email', label: 'Email', format: 'email' }, + { key: 'account', label: 'Account', children: accountSummaryFields }, + ], + }, + { + key: 'rules', + label: 'Rules', + labelKey: 'text', + listItems: [ + { key: 'id', label: 'Rule ID' }, + { key: 'text', label: 'Text' }, + { key: 'hint', label: 'Hint' }, + ], + }, +]; + +const relationshipFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Account ID' }, + { key: 'following', label: 'Following', format: 'boolean' }, + { key: 'followed_by', label: 'Follows You', format: 'boolean' }, + { key: 'requested', label: 'Follow Request Pending', format: 'boolean' }, + { key: 'requested_by', label: 'Has Requested to Follow You', format: 'boolean' }, + { key: 'showing_reblogs', label: 'Showing Their Boosts', format: 'boolean' }, + { key: 'notifying', label: 'Notified of Their Posts', format: 'boolean' }, + { key: 'languages', label: 'Language Filter' }, + { key: 'blocking', label: 'Blocking', format: 'boolean' }, + { key: 'blocked_by', label: 'Blocked by Them', format: 'boolean' }, + { key: 'muting', label: 'Muting', format: 'boolean' }, + { key: 'muting_notifications', label: 'Muting Their Notifications', format: 'boolean' }, + { key: 'muting_expires_at', label: 'Mute Expires At', format: 'datetime' }, + { key: 'domain_blocking', label: 'Blocking Their Domain', format: 'boolean' }, + { key: 'endorsed', label: 'Featured on Profile', format: 'boolean' }, + { key: 'note', label: 'Private Note' }, +]; + +const mutedAccountFields: OutputSchema['fields'] = [ + ...accountFields, + { key: 'mute_expires_at', label: 'Mute Expires At', format: 'datetime' }, +]; + +const notificationFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Notification ID' }, + { key: 'type', label: 'Type' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'group_key', label: 'Group Key' }, + { key: 'account', label: 'From Account', children: accountSummaryFields }, + { key: 'status', label: 'Related Status', children: statusFields }, +]; + +const conversationFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Conversation ID' }, + { key: 'unread', label: 'Unread', format: 'boolean' }, + { + key: 'accounts', + label: 'Participants', + labelKey: 'acct', + listItems: accountSummaryFields, + }, + { key: 'last_status', label: 'Last Status', children: statusFields }, +]; + +const acknowledgementFields: OutputSchema['fields'] = [ + { key: 'success', label: 'Success', format: 'boolean' }, +]; + +function pageSchema({ + itemsKey, + label, + items, + labelKey, +}: { + itemsKey: string; + label: string; + items: OutputSchema['fields']; + labelKey: string; +}): OutputSchema { + return { + fields: [ + { key: itemsKey, label, labelKey, listItems: items }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_max_id', label: 'Next Page Cursor (max_id)' }, + { key: 'prev_min_id', label: 'Previous Page Cursor (min_id)' }, + { key: 'prev_since_id', label: 'Previous Page Cursor (since_id)' }, + ], + }; +} + +function countedListSchema({ + itemsKey, + label, + items, + labelKey, +}: { + itemsKey: string; + label: string; + items: OutputSchema['fields']; + labelKey: string; +}): OutputSchema { + return { + fields: [ + { key: itemsKey, label, labelKey, listItems: items }, + { key: 'count', label: 'Count', format: 'number' }, + ], + }; +} + +function acknowledgementSchema({ idKey, idLabel }: { idKey: string; idLabel: string }): OutputSchema { + return { + fields: [...acknowledgementFields, { key: idKey, label: idLabel }], + }; +} + +export const statusOutputSchema: OutputSchema = { fields: statusFields }; + +export const deletedStatusOutputSchema: OutputSchema = { fields: deletedStatusFields }; + +export const statusContextOutputSchema: OutputSchema = { + fields: [ + { key: 'ancestors', label: 'Ancestors', labelKey: 'url', listItems: statusFields }, + { key: 'descendants', label: 'Replies', labelKey: 'url', listItems: statusFields }, + ], +}; + +export const statusPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'statuses', + label: 'Statuses', + items: statusFields, + labelKey: 'url', +}); + +export const trendingStatusesOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'statuses', + label: 'Trending Statuses', + items: statusFields, + labelKey: 'url', +}); + +export const accountOutputSchema: OutputSchema = { fields: accountFields }; + +export const myAccountOutputSchema: OutputSchema = { fields: myAccountFields }; + +export const accountPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'accounts', + label: 'Accounts', + items: accountFields, + labelKey: 'acct', +}); + +export const searchAccountsOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'accounts', + label: 'Accounts', + items: accountFields, + labelKey: 'acct', +}); + +export const searchOutputSchema: OutputSchema = { + fields: [ + { key: 'accounts', label: 'Accounts', labelKey: 'acct', listItems: accountFields }, + { key: 'statuses', label: 'Statuses', labelKey: 'url', listItems: statusFields }, + { key: 'hashtags', label: 'Hashtags', labelKey: 'name', listItems: tagFields }, + ], +}; + +export const scheduledStatusOutputSchema: OutputSchema = { fields: scheduledStatusFields }; + +export const scheduledStatusPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'scheduled_statuses', + label: 'Scheduled Statuses', + items: scheduledStatusFields, + labelKey: 'scheduled_at', +}); + +export const mappedMediaOutputSchema: OutputSchema = { fields: mappedMediaFields }; + +export const mediaAttachmentOutputSchema: OutputSchema = { fields: mediaAttachmentFields }; + +export const pollOutputSchema: OutputSchema = { fields: pollFields }; + +export const tagOutputSchema: OutputSchema = { fields: tagFields }; + +export const tagPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'tags', + label: 'Hashtags', + items: tagFields, + labelKey: 'name', +}); + +export const trendingTagsOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'tags', + label: 'Trending Hashtags', + items: tagFields, + labelKey: 'name', +}); + +export const trendingLinksOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'links', + label: 'Trending Links', + items: trendingLinkFields, + labelKey: 'title', +}); + +export const listOutputSchema: OutputSchema = { fields: listFields }; + +export const listsOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'lists', + label: 'Lists', + items: listFields, + labelKey: 'title', +}); + +export const filterOutputSchema: OutputSchema = { fields: filterFields }; + +export const filtersOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'filters', + label: 'Filters', + items: filterFields, + labelKey: 'title', +}); + +export const unreadNotificationCountOutputSchema: OutputSchema = { + fields: [{ key: 'count', label: 'Unread Notifications', format: 'number' }], +}; + +export const instanceOutputSchema: OutputSchema = { fields: instanceFields }; + +export const cancelledScheduledStatusOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'scheduled_status_id', + idLabel: 'Cancelled Scheduled Status ID', +}); + +export const deletedMediaOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'media_id', + idLabel: 'Deleted Media ID', +}); + +export const deletedFilterOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'filter_id', + idLabel: 'Deleted Filter ID', +}); + +export const listAcknowledgementOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'list_id', + idLabel: 'List ID', +}); + +export const dismissedNotificationOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'notification_id', + idLabel: 'Dismissed Notification ID', +}); + +export const removedConversationOutputSchema: OutputSchema = acknowledgementSchema({ + idKey: 'conversation_id', + idLabel: 'Removed Conversation ID', +}); + +export const clearedNotificationsOutputSchema: OutputSchema = { fields: acknowledgementFields }; + +export const relationshipOutputSchema: OutputSchema = { fields: relationshipFields }; + +export const relationshipsOutputSchema: OutputSchema = countedListSchema({ + itemsKey: 'relationships', + label: 'Relationships', + items: relationshipFields, + labelKey: 'id', +}); + +export const mutedAccountPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'accounts', + label: 'Muted Accounts', + items: mutedAccountFields, + labelKey: 'acct', +}); + +export const notificationOutputSchema: OutputSchema = { fields: notificationFields }; + +export const notificationPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'notifications', + label: 'Notifications', + items: notificationFields, + labelKey: 'type', +}); + +export const conversationOutputSchema: OutputSchema = { fields: conversationFields }; + +export const conversationPageOutputSchema: OutputSchema = pageSchema({ + itemsKey: 'conversations', + label: 'Conversations', + items: conversationFields, + labelKey: 'id', +}); + +export const postStatusOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'HTTP Status', format: 'number' }, + { key: 'body', label: 'Status', children: statusFields }, + ], +}; diff --git a/packages/pieces/core/crypto/package.json b/packages/pieces/core/crypto/package.json index de733737f090..49e63ac2a7d8 100644 --- a/packages/pieces/core/crypto/package.json +++ b/packages/pieces/core/crypto/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-crypto", - "version": "0.0.28", + "version": "0.0.29", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/crypto/src/lib/actions/base64-decode.ts b/packages/pieces/core/crypto/src/lib/actions/base64-decode.ts index 82af6df797a5..bd05399a2fc2 100644 --- a/packages/pieces/core/crypto/src/lib/actions/base64-decode.ts +++ b/packages/pieces/core/crypto/src/lib/actions/base64-decode.ts @@ -1,4 +1,5 @@ import { Property, createAction } from '@activepieces/pieces-framework'; +import { base64DecodeActionOutputSchema } from '../output-schemas'; export const base64Decode = createAction({ audience: 'both', @@ -7,6 +8,7 @@ export const base64Decode = createAction({ displayName: 'Base64 Decode', description:'Converts base64 text back to plain text.', aiMetadata: { description: 'Decodes a base64-encoded string back to plain text. Pick this to read a base64 field from an API response or header; use Base64 Encode for the opposite direction, and note that a digest from Text to Hash cannot be reversed this way. Requires a base64 text string rather than a binary file, and invalid base64 characters are silently dropped rather than raising an error; pure computation and idempotent.', idempotent: true }, + outputSchema: base64DecodeActionOutputSchema, props: { text: Property.ShortText({ displayName: 'Text', diff --git a/packages/pieces/core/crypto/src/lib/actions/base64-encode.ts b/packages/pieces/core/crypto/src/lib/actions/base64-encode.ts index 1eade88c1ffd..2f397bc093fa 100644 --- a/packages/pieces/core/crypto/src/lib/actions/base64-encode.ts +++ b/packages/pieces/core/crypto/src/lib/actions/base64-encode.ts @@ -1,4 +1,5 @@ import { Property, createAction } from '@activepieces/pieces-framework'; +import { base64EncodeActionOutputSchema } from '../output-schemas'; export const base64Encode = createAction({ audience: 'both', @@ -7,6 +8,7 @@ export const base64Encode = createAction({ displayName: 'Base64 Encode', description: 'Converts plain text into base64 format.', aiMetadata: { description: 'Encodes a plain-text string into base64. Pick this when a downstream API, header or payload field requires base64 (e.g. a Basic-auth credential); use Base64 Decode to reverse it, or Text to Hash if you need a one-way digest rather than reversible encoding. Accepts a text string only, not a binary file; pure computation and idempotent.', idempotent: true }, + outputSchema: base64EncodeActionOutputSchema, props: { text: Property.ShortText({ displayName: 'Text', diff --git a/packages/pieces/core/crypto/src/lib/actions/generate-password.ts b/packages/pieces/core/crypto/src/lib/actions/generate-password.ts index c477a9b390eb..b74a8a5b70b9 100644 --- a/packages/pieces/core/crypto/src/lib/actions/generate-password.ts +++ b/packages/pieces/core/crypto/src/lib/actions/generate-password.ts @@ -2,6 +2,7 @@ import { Property, createAction, } from '@activepieces/pieces-framework'; +import { generatePasswordActionOutputSchema } from '../output-schemas'; import * as z from 'zod/mini' import { propsValidation } from '@activepieces/pieces-common'; @@ -12,6 +13,7 @@ export const generatePassword = createAction({ description: 'Generates a random password with the specified length', aiMetadata: { description: 'Generates a random password of a given length, from either an alphanumeric character set or alphanumeric plus symbols. Pick this when a flow needs a fresh secret or random token to hand to a later step; it is a value generator, not a digest, so use Text to Hash or Generate HMAC Signature to hash existing text. Length must be 256 or less; not idempotent: every call returns a different password, so capture the output once and reuse it rather than re-running.', idempotent: false }, displayName: 'Generate Password', + outputSchema: generatePasswordActionOutputSchema, props: { length: Property.Number({ displayName: 'Password Length', diff --git a/packages/pieces/core/crypto/src/lib/actions/hash-text.ts b/packages/pieces/core/crypto/src/lib/actions/hash-text.ts index 847ba38f02dc..0eebace85d26 100644 --- a/packages/pieces/core/crypto/src/lib/actions/hash-text.ts +++ b/packages/pieces/core/crypto/src/lib/actions/hash-text.ts @@ -1,4 +1,5 @@ import { Property, createAction } from '@activepieces/pieces-framework'; +import { hashTextActionOutputSchema } from '../output-schemas'; import Crypto from 'crypto'; export const hashText = createAction({ @@ -8,6 +9,7 @@ export const hashText = createAction({ description: 'Converts text to a hash value using various hashing algorithms', aiMetadata: { description: 'Computes a one-way cryptographic digest of a text string as a hex value, with the algorithm chosen from MD5, SHA256, SHA512 or SHA3-512. Pick this for checksums, fingerprints or de-duplication keys; use Generate HMAC Signature when the digest must be keyed with a shared secret, and Base64 Encode when you need reversible encoding instead of a hash. Requires the text plus an algorithm; pure computation and idempotent, since identical input always yields the same digest.', idempotent: true }, displayName: 'Text to Hash', + outputSchema: hashTextActionOutputSchema, props: { method: Property.StaticDropdown({ displayName: 'Method', diff --git a/packages/pieces/core/crypto/src/lib/actions/hmac-signature.ts b/packages/pieces/core/crypto/src/lib/actions/hmac-signature.ts index 202b598870cf..8298e42eae69 100644 --- a/packages/pieces/core/crypto/src/lib/actions/hmac-signature.ts +++ b/packages/pieces/core/crypto/src/lib/actions/hmac-signature.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { hmacSignatureActionOutputSchema } from '../output-schemas'; import Crypto from 'crypto'; import { Buffer } from 'buffer'; @@ -10,6 +11,7 @@ export const hmacSignature = createAction({ 'Converts text to a HMAC signed hash value using various hashing algorithms', aiMetadata: { description: 'Computes a keyed HMAC digest of a text string with a secret key, selecting the hash algorithm and how the secret key itself is interpreted (UTF-8, hex or base64). Pick this to sign or verify webhook payloads and API requests that use a shared secret; use Text to Hash for an unkeyed digest and Generate RSA Signature for asymmetric private-key signing. The secret-key encoding must match how the key was issued or the signature will not match; pure computation and idempotent.', idempotent: true }, displayName: 'Generate HMAC Signature', + outputSchema: hmacSignatureActionOutputSchema, props: { secretKey: Property.ShortText({ displayName: 'Secret key', diff --git a/packages/pieces/core/crypto/src/lib/actions/rsa-signature.ts b/packages/pieces/core/crypto/src/lib/actions/rsa-signature.ts index 15babe9cdebc..4a8fd0ab1595 100644 --- a/packages/pieces/core/crypto/src/lib/actions/rsa-signature.ts +++ b/packages/pieces/core/crypto/src/lib/actions/rsa-signature.ts @@ -1,4 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; +import { rsaSignatureActionOutputSchema } from '../output-schemas'; import Crypto from 'crypto'; export const rsaSignature = createAction({ @@ -9,6 +10,7 @@ export const rsaSignature = createAction({ description: 'Signs text with an RSA private key using SHA-256, SHA-384, or SHA-512 (RSA-SHA256 by default)', aiMetadata: { description: 'Signs a text payload with an RSA private key in PEM format (PKCS#1 or PKCS#8), hashing with SHA-256, SHA-384 or SHA-512. Pick this for asymmetric request signing where the verifier holds only the public key; use Generate HMAC Signature when both sides share one symmetric secret, and Text to Hash for a plain unkeyed digest. Requires the private key plus its passphrase if the key is encrypted; signing uses deterministic PKCS#1 v1.5 padding, so it is idempotent.', idempotent: true }, + outputSchema: rsaSignatureActionOutputSchema, props: { privateKey: Property.LongText({ displayName: 'Private Key', diff --git a/packages/pieces/core/crypto/src/lib/output-schemas.ts b/packages/pieces/core/crypto/src/lib/output-schemas.ts index 0af43f1c2a0d..c82402c04568 100644 --- a/packages/pieces/core/crypto/src/lib/output-schemas.ts +++ b/packages/pieces/core/crypto/src/lib/output-schemas.ts @@ -8,3 +8,54 @@ export const openpgpEncryptActionOutputSchema: OutputSchema = { { key: 'error', label: 'Error' }, ], }; + +export const base64EncodeActionOutputSchema = wholeStringOutput({ + key: 'encodedText', + label: 'Base64 Text', + description: 'The input text encoded as a base64 string.', +}); + +export const base64DecodeActionOutputSchema = wholeStringOutput({ + key: 'decodedText', + label: 'Decoded Text', + description: 'The plain text decoded from the base64 input.', +}); + +export const generatePasswordActionOutputSchema = wholeStringOutput({ + key: 'password', + label: 'Password', + description: + 'The randomly generated password, at the requested length and character set.', +}); + +export const hashTextActionOutputSchema = wholeStringOutput({ + key: 'hash', + label: 'Hash', + description: 'The hex-encoded digest of the input text.', +}); + +export const hmacSignatureActionOutputSchema = wholeStringOutput({ + key: 'signature', + label: 'HMAC Signature', + description: + 'The HMAC digest of the input text, encoded as hex or base64 per the Output Encoding setting.', +}); + +export const rsaSignatureActionOutputSchema = wholeStringOutput({ + key: 'signature', + label: 'RSA Signature', + description: + 'The RSA signature of the input text, encoded as base64 or hex per the Output Encoding setting.', +}); + +function wholeStringOutput({ + key, + label, + description, +}: { + key: string; + label: string; + description: string; +}): OutputSchema { + return { fields: [{ key, label, value: '', description }] }; +} From 5e98123cdfaf1a60e8c469b48ce9062f668abc3e Mon Sep 17 00:00:00 2001 From: Ahmad Tash <144666528+AhmadTash@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:03:28 +0300 Subject: [PATCH 2/8] fix(builder): notes can be added again and drag previews follow the cursor (#15760) --- brain/knowledge/flows-execution/flows.md | 1 + .../nodes/note-node/note-drag-overlay.tsx | 14 +- .../nodes/step-node/step-drag-overlay.tsx | 12 +- .../components/sidebar/dashboard/index.tsx | 1 - .../note-node/note-drag-overlay.test.tsx | 135 ++++++++++++++++++ .../step-node/step-drag-overlay.test.tsx | 91 ++++++++++++ 6 files changed, 239 insertions(+), 15 deletions(-) delete mode 100644 packages/web/src/app/components/sidebar/dashboard/index.tsx create mode 100644 packages/web/test/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.test.tsx create mode 100644 packages/web/test/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.test.tsx diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md index 15668fb66382..22020f07470e 100644 --- a/brain/knowledge/flows-execution/flows.md +++ b/brain/knowledge/flows-execution/flows.md @@ -32,6 +32,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger + - List filtering: `folderId` uses the string sentinel `"NULL"` for uncategorized; `folderIds` (array) loads all foldered flows in one request. - Builder is Zustand-sliced (flow/run/canvas/step-form/piece-selector state). Canvas supports vertical (default) and horizontal orientations, and PNG export via a hand-rolled clone-and-rasterize pipeline. - **`Popover.Trigger asChild` always composes its own open-toggle onto the child's click, even when `open`/`onOpenChange` is externally controlled — a `preventDefault()`-shaped guard is the only thing that stops it.** Radix wires `onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}` on the trigger, and `composeEventHandlers` skips the second handler only if `event.defaultPrevented`. `ApStepCanvasNode` wraps *every* step (trigger and action) in `` (`flow-canvas/nodes/step-node/index.tsx`), but that prop only guards the app's own `onClick` — it was `handleStepClick`'s incidental `e.preventDefault()` that actually suppressed Radix's toggle for ordinary steps. PR #14405 ("close trigger piece selector on outside/repeat click") removed that `preventDefault()` and made `pieces-selector/index.tsx`'s `onOpenChange` unconditionally honor `open` for any step id — fixing a real empty-trigger close/repeat-click race, but with nothing scoping the new open-branch to the empty trigger specifically. Regression window: 2026-07-28 (#14405) to 2026-08-11 — any plain click on a *configured* step's body reopened its own piece selector, not just the empty trigger (case 1) or an explicit Replace (case 2, which sets the state directly from the context menu and never touches this trigger). Fix: gate the `onOpenChange` open-branch on `isForEmptyTrigger || openSelectorOnClick` instead of honoring every toggle — Radix's own attempt to open is simply ignored (state doesn't change, so the controlled `open` prop stays false) when neither condition holds. Confirmed both the break and the fix by reproducing the exact `Popover.Root`/`Trigger asChild` wiring against the real installed `radix-ui` package in a throwaway vitest — not just static reading. The general lesson (Radix always attempts the toggle; controlled mode only means *you* decide whether to honor it) applies to every controlled Radix `Trigger` in this codebase, not just this one. +- **Anything drawn in canvas coordinates must measure the canvas element, never infer its offset from app chrome.** The note and step drag previews are `position: absolute` inside the canvas and used to subtract `#project-sidebar`'s width plus a hardcoded 60px header; when the chat-first rail (#15005) replaced that sidebar the id vanished, the width read 0, and Add note silently stopped working — the preview drew a sidebar-width right of the cursor, so the placing click always missed it. Take the origin from xyflow's `useStore((s) => s.domNode)?.getBoundingClientRect()`. `position: fixed` is no way out: an ancestor carries `will-change: transform`, which makes it the containing block. - **A stray DRAFT row makes a published flow look blank.** "Latest version" is resolved by `getFlowVersionOrThrow({ versionId: undefined })`, which is just `ORDER BY created DESC` with no state filter — so any DRAFT created after the LOCKED version *is* what the builder opens. This is why a half-created draft is a user-visible outage, not DB litter: recovering needs the row deleted or a `USE_AS_DRAFT`. Editing a published flow used to commit the empty DRAFT and import the locked content into it as two separate writes, so an import failure (field case: `RangeError: Maximum call stack size exceeded` on deeply nested flows — the recursive `sanitizeObjectForPostgresql` before the save is the prime suspect) left the flow rendering empty forever. GIT-1590 / Pylon #5225; the `RangeError` itself is still open as GIT-1593. - **Draft creation is now atomic, in two different ways — know which one you're in.** `createNewDraftIfVersionIsPublished` runs `createEmptyVersion` + the IMPORT_FLOW loop in one `transaction()`, threading the `entityManager` down through `applyOperation` to the `updateLastModified` side effect. The *user's* operation deliberately stays outside that transaction (it would hold Postgres open across `prepareRequest` piece-metadata fetches and non-rollbackable file/webhook side effects) and is instead undone by a compensating `delete` of the freshly created draft. `flowService.create` wraps the flow row + first empty version the same way, so a failure can't leave a zero-version, unopenable flow. - **`flowVersionSideEffects.preApplyOperation` writes on the default connection, so it escapes any caller transaction.** `handleSampleDataDeletion` and `handleUpdateTriggerWebhookSimulation` take no `entityManager`; a write they make from inside a `transaction()` survives its rollback. They early-return for `IMPORT_FLOW`/`UPDATE_SAMPLE_DATA_INFO`, so the transactional path above is safe today — but adding an operation type to it silently reintroduces partial commits. `updateLastModified` sits outside that swallow-all catch on purpose: a swallowed statement failure inside a transaction poisons it and resurfaces as a confusing "transaction is aborted" error on the next statement. diff --git a/packages/web/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx b/packages/web/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx index 2a20f5a20ea5..63672273b9c0 100644 --- a/packages/web/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx +++ b/packages/web/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx @@ -1,9 +1,7 @@ import { isNil } from '@activepieces/core-utils'; -import { useReactFlow } from '@xyflow/react'; +import { useReactFlow, useStore } from '@xyflow/react'; import { useRef, useState } from 'react'; -import { SIDEBAR_ID } from '@/app/components/sidebar/dashboard'; - import { useBuilderStateContext } from '../../../builder-hooks'; import { NoteDragOverlayMode } from '../../../state/notes-state'; import { @@ -18,7 +16,8 @@ const NoteDragOverlay = () => { const { cursorPosition } = useCursorPosition(); const [overlayPosition, setOverlayPosition] = useState(cursorPosition); - const sidebar = document.getElementById(SIDEBAR_ID); + const canvasElement = useStore((state) => state.domNode); + const canvasRect = canvasElement?.getBoundingClientRect(); const [draggedNote, noteDragOverlayMode, addNote, draggedNoteOffset] = useBuilderStateContext((state) => [ state.draggedNote, @@ -28,7 +27,6 @@ const NoteDragOverlay = () => { ]); const reactFlow = useReactFlow(); const containerRef = useRef(null); - const sidebarWidth = sidebar?.clientWidth ?? 0; const nodeSizeWithZoom = { width: (draggedNote?.size.width ?? 0) * reactFlow.getZoom(), @@ -42,10 +40,8 @@ const NoteDragOverlay = () => { ? draggedNoteOffset.y : nodeSizeWithZoom.height / 2; - const left = `${overlayPosition.x - offsetX - sidebarWidth}px`; - const top = `${ - overlayPosition.y - offsetY - flowCanvasConsts.BUILDER_HEADER_HEIGHT - }px`; + const left = `${overlayPosition.x - offsetX - (canvasRect?.left ?? 0)}px`; + const top = `${overlayPosition.y - offsetY - (canvasRect?.top ?? 0)}px`; useCursorPositionEffect((position) => { setOverlayPosition(position); }); diff --git a/packages/web/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx b/packages/web/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx index 24aee8678afd..bd09d14790f7 100644 --- a/packages/web/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx +++ b/packages/web/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx @@ -1,8 +1,8 @@ import { FlowAction, FlowTrigger } from '@activepieces/shared'; +import { useStore } from '@xyflow/react'; import { t } from 'i18next'; import { useState } from 'react'; -import { SIDEBAR_ID } from '@/app/components/sidebar/dashboard'; import { stepsHooks } from '@/features/pieces'; import { @@ -15,15 +15,17 @@ const StepDragOverlay = ({ step }: { step: FlowAction | FlowTrigger }) => { const { cursorPosition } = useCursorPosition(); const [overlayPosition, setOverlayPosition] = useState(cursorPosition); - const sidebar = document.getElementById(SIDEBAR_ID); - const sidebarWidth = sidebar?.clientWidth ?? 0; + const canvasElement = useStore((state) => state.domNode); + const canvasRect = canvasElement?.getBoundingClientRect(); const left = `${ overlayPosition.x - flowCanvasConsts.STEP_DRAG_OVERLAY_WIDTH / 2 - - sidebarWidth + (canvasRect?.left ?? 0) }px`; const top = `${ - overlayPosition.y - flowCanvasConsts.STEP_DRAG_OVERLAY_HEIGHT - 20 + overlayPosition.y - + flowCanvasConsts.STEP_DRAG_OVERLAY_HEIGHT / 2 - + (canvasRect?.top ?? 0) }px`; const { stepMetadata } = stepsHooks.useStepMetadata({ step, diff --git a/packages/web/src/app/components/sidebar/dashboard/index.tsx b/packages/web/src/app/components/sidebar/dashboard/index.tsx deleted file mode 100644 index 93c84e1549c9..000000000000 --- a/packages/web/src/app/components/sidebar/dashboard/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const SIDEBAR_ID = 'project-sidebar'; diff --git a/packages/web/test/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.test.tsx b/packages/web/test/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.test.tsx new file mode 100644 index 000000000000..518f87b58ec0 --- /dev/null +++ b/packages/web/test/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable testing-library/no-unnecessary-act */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import NoteDragOverlay from '@/app/builder/flow-canvas/nodes/note-node/note-drag-overlay'; +import { NoteDragOverlayMode } from '@/app/builder/state/notes-state'; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const canvasOrigin = vi.hoisted(() => ({ left: 255, top: 68 })); + +const builderState = vi.hoisted((): { current: DraggedNoteSlice } => ({ + current: { + draggedNote: null, + noteDragOverlayMode: null, + addNote: () => undefined, + draggedNoteOffset: null, + }, +})); + +vi.mock('@xyflow/react', () => ({ + useReactFlow: () => ({ + getZoom: () => 1, + screenToFlowPosition: (position: Point) => position, + }), + useStore: (selector: (state: CanvasState) => T) => + selector({ domNode: { getBoundingClientRect: () => canvasOrigin } }), +})); + +vi.mock('@/app/builder/builder-hooks', () => ({ + useBuilderStateContext: (selector: (state: DraggedNoteSlice) => T) => + selector(builderState.current), +})); + +vi.mock('@/app/builder/state/notes-state', () => ({ + NoteDragOverlayMode: { CREATE: 'create', MOVE: 'move' }, +})); + +vi.mock('@/app/builder/flow-canvas/nodes/note-node', () => ({ + NoteContent: () => null, +})); + +vi.mock('@/app/builder/flow-canvas/utils/consts', () => ({ + flowCanvasConsts: { DEFAULT_NOTE_CONTENT: '', DEFAULT_NOTE_COLOR: 'blue' }, +})); + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +describe('NoteDragOverlay', () => { + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; + builderState.current = { + draggedNote: null, + noteDragOverlayMode: null, + addNote: () => undefined, + draggedNoteOffset: null, + }; + }); + + it('centres the new-note preview on the cursor, measured from the canvas element', () => { + mountOverlay(); + movePointer({ x: 600, y: 700 }); + builderState.current = { + ...builderState.current, + draggedNote: { size: { width: 150, height: 150 } }, + noteDragOverlayMode: NoteDragOverlayMode.CREATE, + }; + renderOverlay(); + + const overlay = container?.querySelector('.note-drag-overlay'); + expect(overlay?.style.left).toBe(`${600 - 150 / 2 - canvasOrigin.left}px`); + expect(overlay?.style.top).toBe(`${700 - 150 / 2 - canvasOrigin.top}px`); + }); + + it('keeps the grabbed point of a moved note under the cursor', () => { + mountOverlay(); + movePointer({ x: 600, y: 700 }); + builderState.current = { + ...builderState.current, + draggedNote: { size: { width: 275, height: 175 } }, + noteDragOverlayMode: NoteDragOverlayMode.MOVE, + draggedNoteOffset: { x: 40, y: 125 }, + }; + renderOverlay(); + + const overlay = container?.querySelector('.note-drag-overlay'); + expect(overlay?.style.left).toBe(`${600 - 40 - canvasOrigin.left}px`); + expect(overlay?.style.top).toBe(`${700 - 125 - canvasOrigin.top}px`); + }); +}); + +function mountOverlay(): void { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + renderOverlay(); +} + +function renderOverlay(): void { + act(() => root?.render()); +} + +function movePointer({ x, y }: Point): void { + act(() => { + window.dispatchEvent( + new MouseEvent('pointermove', { clientX: x, clientY: y }), + ); + }); +} + +type Point = { x: number; y: number }; + +type CanvasState = { + domNode: { getBoundingClientRect: () => { left: number; top: number } }; +}; + +type DraggedNoteSlice = { + draggedNote: { size: { width: number; height: number } } | null; + noteDragOverlayMode: NoteDragOverlayMode | null; + addNote: () => void; + draggedNoteOffset: Point | null; +}; diff --git a/packages/web/test/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.test.tsx b/packages/web/test/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.test.tsx new file mode 100644 index 000000000000..4a8959643b75 --- /dev/null +++ b/packages/web/test/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.test.tsx @@ -0,0 +1,91 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable testing-library/no-unnecessary-act */ +import { FlowAction, FlowActionType } from '@activepieces/shared'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import StepDragOverlay from '@/app/builder/flow-canvas/nodes/step-node/step-drag-overlay'; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const canvasOrigin = vi.hoisted(() => ({ left: 255, top: 68 })); + +vi.mock('@xyflow/react', () => ({ + useStore: (selector: (state: CanvasState) => T) => + selector({ domNode: { getBoundingClientRect: () => canvasOrigin } }), +})); + +vi.mock('@/features/pieces', () => ({ + stepsHooks: { useStepMetadata: () => ({ stepMetadata: undefined }) }, +})); + +vi.mock('@/app/builder/flow-canvas/utils/consts', () => ({ + flowCanvasConsts: { + STEP_DRAG_OVERLAY_WIDTH: 75, + STEP_DRAG_OVERLAY_HEIGHT: 75, + }, +})); + +vi.mock('i18next', () => ({ t: (key: string) => key })); + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +describe('StepDragOverlay', () => { + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; + }); + + it('centres the step preview on the cursor, measured from the canvas element', () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); + + act(() => { + window.dispatchEvent( + new MouseEvent('pointermove', { clientX: 509, clientY: 591 }), + ); + }); + + const overlay = container.querySelector( + '#dragged-step-overlay', + ); + expect(overlay?.style.left).toBe(`${509 - 75 / 2 - canvasOrigin.left}px`); + expect(overlay?.style.top).toBe(`${591 - 75 / 2 - canvasOrigin.top}px`); + }); +}); + +function buildCodeStep(): FlowAction { + return { + name: 'step_1', + valid: true, + displayName: 'Step 1', + skip: false, + lastUpdatedDate: '2026-09-24T00:00:00.000Z', + type: FlowActionType.CODE, + settings: { + sourceCode: { code: '', packageJson: '{}' }, + input: {}, + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + }, + }; +} + +type CanvasState = { + domNode: { getBoundingClientRect: () => { left: number; top: number } }; +}; From f39970f33b0fb35f1404dbe3dea1f81f50d5baba Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Fri, 25 Sep 2026 14:42:53 +0300 Subject: [PATCH 3/8] feat(hugging-face): let agents search the Hub, read repos and papers, and run chat and embeddings (#15793) --- bun.lock | 3 +- .../community/hugging-face/package.json | 8 +- .../hugging-face/src/i18n/translation.json | 244 +++++- .../community/hugging-face/src/index.ts | 62 +- .../src/lib/actions/chat-completion.ts | 2 +- .../src/lib/actions/compare-repo-revisions.ts | 58 ++ .../actions/document-question-answering.ts | 2 +- .../lib/actions/generate-chat-completion.ts | 241 ++++++ .../src/lib/actions/generate-embeddings.ts | 147 ++++ .../src/lib/actions/get-collection.ts | 53 ++ .../src/lib/actions/get-current-user.ts | 86 ++ .../src/lib/actions/get-dataset.ts | 36 + .../src/lib/actions/get-discussion.ts | 45 ++ .../hugging-face/src/lib/actions/get-model.ts | 36 + .../lib/actions/get-organization-overview.ts | 35 + .../hugging-face/src/lib/actions/get-paper.ts | 58 ++ .../src/lib/actions/get-repo-paths-info.ts | 56 ++ .../src/lib/actions/get-repo-security-scan.ts | 37 + .../src/lib/actions/get-repo-size.ts | 44 + .../hugging-face/src/lib/actions/get-space.ts | 36 + .../src/lib/actions/get-user-overview.ts | 35 + .../src/lib/actions/language-translation.ts | 2 +- .../src/lib/actions/list-collections.ts | 78 ++ .../src/lib/actions/list-daily-papers.ts | 91 +++ .../src/lib/actions/list-discussions.ts | 112 +++ .../src/lib/actions/list-hub-tags.ts | 88 ++ .../lib/actions/list-organization-members.ts | 62 ++ .../src/lib/actions/list-repo-commits.ts | 57 ++ .../src/lib/actions/list-repo-files.ts | 75 ++ .../src/lib/actions/list-repo-refs.ts | 44 + .../src/lib/actions/list-trending-repos.ts | 64 ++ .../src/lib/actions/read-repo-file.ts | 53 ++ .../src/lib/actions/search-datasets.ts | 100 +++ .../src/lib/actions/search-models.ts | 101 +++ .../src/lib/actions/search-papers.ts | 49 ++ .../src/lib/actions/search-spaces.ts | 85 ++ .../src/lib/actions/text-classification.ts | 2 +- .../src/lib/actions/text-summarization.ts | 2 +- .../hugging-face/src/lib/common/hub-client.ts | 279 +++++++ .../hugging-face/src/lib/common/inference.ts | 132 +++ .../hugging-face/src/lib/common/props.ts | 88 ++ .../hugging-face/src/lib/common/repo.ts | 93 +++ .../hugging-face/src/lib/common/utils.ts | 60 ++ .../hugging-face/src/lib/output-schemas.ts | 749 ++++++++++++++++++ .../community/hugging-face/test/helpers.ts | 31 + .../community/hugging-face/test/hub.test.ts | 280 +++++++ .../hugging-face/test/inference.test.ts | 252 ++++++ .../community/hugging-face/vitest.config.ts | 19 + 48 files changed, 4359 insertions(+), 13 deletions(-) create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/compare-repo-revisions.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/generate-chat-completion.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/generate-embeddings.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-collection.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-current-user.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-dataset.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-discussion.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-model.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-organization-overview.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-paper.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-repo-paths-info.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-repo-security-scan.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-repo-size.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-space.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/get-user-overview.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-collections.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-daily-papers.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-discussions.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-hub-tags.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-organization-members.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-repo-commits.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-repo-files.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-repo-refs.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/list-trending-repos.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/read-repo-file.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/search-datasets.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/search-models.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/search-papers.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/actions/search-spaces.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/common/hub-client.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/common/inference.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/common/props.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/common/repo.ts create mode 100644 packages/pieces/community/hugging-face/src/lib/common/utils.ts create mode 100644 packages/pieces/community/hugging-face/test/helpers.ts create mode 100644 packages/pieces/community/hugging-face/test/hub.test.ts create mode 100644 packages/pieces/community/hugging-face/test/inference.test.ts create mode 100644 packages/pieces/community/hugging-face/vitest.config.ts diff --git a/bun.lock b/bun.lock index 1486ea18d913..18c874d0cb80 100644 --- a/bun.lock +++ b/bun.lock @@ -4362,7 +4362,7 @@ }, "packages/pieces/community/hugging-face": { "name": "@activepieces/piece-hugging-face", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4373,6 +4373,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/hume-ai": { diff --git a/packages/pieces/community/hugging-face/package.json b/packages/pieces/community/hugging-face/package.json index f0a51b89cb87..0b709e6b7a24 100644 --- a/packages/pieces/community/hugging-face/package.json +++ b/packages/pieces/community/hugging-face/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-hugging-face", - "version": "0.1.8", + "version": "0.2.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { @@ -12,11 +12,13 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" }, "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" } } diff --git a/packages/pieces/community/hugging-face/src/i18n/translation.json b/packages/pieces/community/hugging-face/src/i18n/translation.json index da34bce9c7dd..b616da725aec 100644 --- a/packages/pieces/community/hugging-face/src/i18n/translation.json +++ b/packages/pieces/community/hugging-face/src/i18n/translation.json @@ -9,6 +9,35 @@ "Create Image": "Create Image", "Object Detection": "Object Detection", "Image Classification": "Image Classification", + "Search Models": "Search Models", + "Search Datasets": "Search Datasets", + "Search Spaces": "Search Spaces", + "Get Model": "Get Model", + "Get Dataset": "Get Dataset", + "Get Space": "Get Space", + "List Trending Repos": "List Trending Repos", + "List Hub Tags by Type": "List Hub Tags by Type", + "List Repo Files": "List Repo Files", + "Get File/Folder Info": "Get File/Folder Info", + "Read Repo File": "Read Repo File", + "List Repo Commits": "List Repo Commits", + "List Branches & Tags": "List Branches & Tags", + "Compare Revisions": "Compare Revisions", + "Get Repo Size": "Get Repo Size", + "Get Security Scan": "Get Security Scan", + "Get Current User & Token": "Get Current User & Token", + "Get User Profile": "Get User Profile", + "Get Organization Profile": "Get Organization Profile", + "List Organization Members": "List Organization Members", + "List Discussions & PRs": "List Discussions & PRs", + "Get Discussion or PR": "Get Discussion or PR", + "List Daily Papers": "List Daily Papers", + "Search Papers": "Search Papers", + "Get Paper": "Get Paper", + "List Collections": "List Collections", + "Get Collection": "Get Collection", + "Generate Chat Completion": "Generate Chat Completion", + "Generate Text Embeddings": "Generate Text Embeddings", "Answer questions from document images using Hugging Face models": "Answer questions from document images using Hugging Face models", "Translate text between languages using specialized Hugging Face translation models": "Translate text between languages using specialized Hugging Face translation models", "Classify text into categories using Hugging Face models - includes zero-shot classification for custom categories": "Classify text into categories using Hugging Face models - includes zero-shot classification for custom categories", @@ -17,6 +46,35 @@ "Generate stunning images from text prompts using state-of-the-art diffusion models - perfect for marketing, product design, and creative content": "Generate stunning images from text prompts using state-of-the-art diffusion models - perfect for marketing, product design, and creative content", "Detect and locate objects in images with precise bounding boxes - perfect for inventory management, content moderation, and automated tagging": "Detect and locate objects in images with precise bounding boxes - perfect for inventory management, content moderation, and automated tagging", "Classify images with pre-trained models or custom categories - perfect for content moderation, automated tagging, and smart asset management": "Classify images with pre-trained models or custom categories - perfect for content moderation, automated tagging, and smart asset management", + "Search and filter models on the Hugging Face Hub.": "Search and filter models on the Hugging Face Hub.", + "Search and filter datasets on the Hugging Face Hub.": "Search and filter datasets on the Hugging Face Hub.", + "Search and filter Spaces (hosted ML apps) on the Hugging Face Hub.": "Search and filter Spaces (hosted ML apps) on the Hugging Face Hub.", + "Get the Hub metadata of one model repository.": "Get the Hub metadata of one model repository.", + "Get the Hub metadata of one dataset repository.": "Get the Hub metadata of one dataset repository.", + "Get the Hub metadata of one Space.": "Get the Hub metadata of one Space.", + "List the repositories currently trending on the Hugging Face Hub.": "List the repositories currently trending on the Hugging Face Hub.", + "List the tags available for filtering models or datasets, grouped by tag type.": "List the tags available for filtering models or datasets, grouped by tag type.", + "List the files and folders of a model, dataset or Space repository.": "List the files and folders of a model, dataset or Space repository.", + "Get size, LFS and last-commit details for specific files or folders in a repository.": "Get size, LFS and last-commit details for specific files or folders in a repository.", + "Read a text file (such as README.md or config.json) from a Hub repository.": "Read a text file (such as README.md or config.json) from a Hub repository.", + "List the commit history of a branch or revision in a Hub repository.": "List the commit history of a branch or revision in a Hub repository.", + "List the branches, tags and converted refs of a Hub repository.": "List the branches, tags and converted refs of a Hub repository.", + "Get the git diff between two revisions of a Hub repository.": "Get the git diff between two revisions of a Hub repository.", + "Get the total size in bytes of a repository, or of one folder in it.": "Get the total size in bytes of a repository, or of one folder in it.", + "Get the malware and pickle security-scan status of a repository.": "Get the malware and pickle security-scan status of a repository.", + "Get the Hugging Face account behind the connection and the role of its access token.": "Get the Hugging Face account behind the connection and the role of its access token.", + "Get the public profile of a Hugging Face user.": "Get the public profile of a Hugging Face user.", + "Get the public profile of a Hugging Face organization.": "Get the public profile of a Hugging Face organization.", + "List the members of a Hugging Face organization.": "List the members of a Hugging Face organization.", + "List the discussions and pull requests of a Hub repository.": "List the discussions and pull requests of a Hub repository.", + "Get one discussion or pull request of a Hub repository, with its comments and events.": "Get one discussion or pull request of a Hub repository, with its comments and events.", + "List the research papers featured on Hugging Face's Daily Papers page.": "List the research papers featured on Hugging Face's Daily Papers page.", + "Search research papers indexed on Hugging Face.": "Search research papers indexed on Hugging Face.", + "Get the details of one research paper on Hugging Face.": "Get the details of one research paper on Hugging Face.", + "List or search curated collections on the Hugging Face Hub.": "List or search curated collections on the Hugging Face Hub.", + "Get one Hugging Face collection with all of its items.": "Get one Hugging Face collection with all of its items.", + "Generate an assistant reply from a list of chat messages with a model served by Inference Providers.": "Generate an assistant reply from a list of chat messages with a model served by Inference Providers.", + "Turn text into embedding vectors with a feature-extraction model served by Inference Providers.": "Turn text into embedding vectors with a feature-extraction model served by Inference Providers.", "Model": "Model", "Document Image": "Document Image", "Question": "Question", @@ -37,7 +95,6 @@ "Zero-Shot Model": "Zero-Shot Model", "Custom Categories": "Custom Categories", "Pre-trained Model": "Pre-trained Model", - "Search Models": "Search Models", "Text to Classify": "Text to Classify", "Number of Results": "Number of Results", "Output Function": "Output Function", @@ -85,6 +142,61 @@ "Image File": "Image File", "Image URL": "Image URL", "Classification Template": "Classification Template", + "Search": "Search", + "Author": "Author", + "Tag Filters": "Tag Filters", + "Pipeline Task": "Pipeline Task", + "Inference Provider": "Inference Provider", + "Sort By": "Sort By", + "Limit": "Limit", + "Include Full Details": "Include Full Details", + "Cursor": "Cursor", + "Gated": "Gated", + "Repository ID": "Repository ID", + "Revision": "Revision", + "Repository Type": "Repository Type", + "Tag Type": "Tag Type", + "Folder Path": "Folder Path", + "Recursive": "Recursive", + "Include Last Commit": "Include Last Commit", + "Paths": "Paths", + "File Path": "File Path", + "Page": "Page", + "Include Pull Request Refs": "Include Pull Request Refs", + "Base Revision": "Base Revision", + "Head Revision": "Head Revision", + "Username": "Username", + "Organization": "Organization", + "Email": "Email", + "Kind": "Kind", + "Status": "Status", + "Discussion Number": "Discussion Number", + "Date": "Date", + "Week": "Week", + "Month": "Month", + "Submitter": "Submitter", + "Query": "Query", + "Paper ID": "Paper ID", + "Include Comments": "Include Comments", + "Include Submission Deadline": "Include Submission Deadline", + "Owner": "Owner", + "Contains Item": "Contains Item", + "Collection Slug": "Collection Slug", + "Provider": "Provider", + "Messages": "Messages", + "System Prompt": "System Prompt", + "Max Tokens": "Max Tokens", + "Temperature": "Temperature", + "Top P": "Top P", + "Stop Sequences": "Stop Sequences", + "Seed": "Seed", + "Response Format": "Response Format", + "JSON Schema": "JSON Schema", + "Texts": "Texts", + "Normalize": "Normalize", + "Truncate": "Truncate", + "Truncation Direction": "Truncation Direction", + "Prompt Name": "Prompt Name", "Hugging Face document question answering model": "Hugging Face document question answering model", "Image of the document to analyze (invoice, contract, etc.)": "Image of the document to analyze (invoice, contract, etc.)", "Question to ask about the document (e.g., 'What is the invoice total?')": "Question to ask about the document (e.g., 'What is the invoice total?')", @@ -163,6 +275,89 @@ "Maximum number of classification results to return (1-20)": "Maximum number of classification results to return (1-20)", "Minimum confidence score for results (0.0-1.0)": "Minimum confidence score for results (0.0-1.0)", "How to structure the classification results": "How to structure the classification results", + "Free-text match on the model ID, for example 'llama' or 'bert-base'.": "Free-text match on the model ID, for example 'llama' or 'bert-base'.", + "Only models owned by this user or organization, for example 'meta-llama'.": "Only models owned by this user or organization, for example 'meta-llama'.", + "Hub tags every result must carry, for example 'text-classification', 'pytorch', 'license:apache-2.0' or 'language:en'. Discover valid values with List Hub Tags by Type.": "Hub tags every result must carry, for example 'text-classification', 'pytorch', 'license:apache-2.0' or 'language:en'. Discover valid values with List Hub Tags by Type.", + "Only models for this task, for example 'text-generation', 'feature-extraction' or 'image-classification'.": "Only models for this task, for example 'text-generation', 'feature-extraction' or 'image-classification'.", + "Only models served by Inference Providers: 'all' for any provider, or one provider name such as 'groq', 'together' or 'hf-inference'.": "Only models served by Inference Providers: 'all' for any provider, or one provider name such as 'groq', 'together' or 'hf-inference'.", + "Sort order (always descending). Defaults to trending.": "Sort order (always descending). Defaults to trending.", + "Maximum number of results in this page (1 to 1000).": "Maximum number of results in this page (1 to 1000).", + "Return extra fields such as the file list and last-modified date for each model.": "Return extra fields such as the file list and last-modified date for each model.", + "The 'next_cursor' value returned by a previous call, to fetch the next page. Leave empty for the first page.": "The 'next_cursor' value returned by a previous call, to fetch the next page. Leave empty for the first page.", + "Free-text match on the dataset ID, for example 'imdb' or 'squad'.": "Free-text match on the dataset ID, for example 'imdb' or 'squad'.", + "Only datasets owned by this user or organization, for example 'stanfordnlp'.": "Only datasets owned by this user or organization, for example 'stanfordnlp'.", + "Hub tags every result must carry, for example 'task_categories:text-classification', 'language:en' or 'size_categories:10K/', for example 'models/openai-community/gpt2', 'datasets/stanfordnlp/imdb' or 'papers/2307.09288'.": "Only collections that contain this item, written as '/', for example 'models/openai-community/gpt2', 'datasets/stanfordnlp/imdb' or 'papers/2307.09288'.", + "Only collections whose title matches this text.": "Only collections whose title matches this text.", + "Sort order. Defaults to trending.": "Sort order. Defaults to trending.", + "The full collection slug in 'namespace/title-id' form, for example 'meta-llama/llama-32-66f448ffc8c32f949b04c8cf' (the part after huggingface.co/collections/).": "The full collection slug in 'namespace/title-id' form, for example 'meta-llama/llama-32-66f448ffc8c32f949b04c8cf' (the part after huggingface.co/collections/).", + "Model ID served by Inference Providers, for example 'openai/gpt-oss-20b' or 'Qwen/Qwen3-8B'. Find others with Search Models (pipeline_tag 'text-generation', inference_provider 'all'); append ':' (for example ':groq') or ':preferred' to pick the provider. Leave empty to use openai/gpt-oss-20b.": "Model ID served by Inference Providers, for example 'openai/gpt-oss-20b' or 'Qwen/Qwen3-8B'. Find others with Search Models (pipeline_tag 'text-generation', inference_provider 'all'); append ':' (for example ':groq') or ':preferred' to pick the provider. Leave empty to use openai/gpt-oss-20b.", + "Inference provider to run the model on. Leave empty (or 'auto') to use the first available provider in your Hugging Face provider preference order.": "Inference provider to run the model on. Leave empty (or 'auto') to use the first available provider in your Hugging Face provider preference order.", + "The conversation as a JSON array of {\"role\", \"content\"} objects, oldest first, for example [{\"role\": \"user\", \"content\": \"Summarize this in one line: ...\"}]. Roles are system, user and assistant. For vision models, content may be an array of parts such as {\"type\": \"text\", \"text\": \"...\"} and {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://...\"}}.": "The conversation as a JSON array of {\"role\", \"content\"} objects, oldest first, for example [{\"role\": \"user\", \"content\": \"Summarize this in one line: ...\"}]. Roles are system, user and assistant. For vision models, content may be an array of parts such as {\"type\": \"text\", \"text\": \"...\"} and {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://...\"}}.", + "Optional instructions sent as a leading system message. Nothing is added when empty.": "Optional instructions sent as a leading system message. Nothing is added when empty.", + "Maximum number of tokens to generate in the reply, for example 512. Reasoning models such as openai/gpt-oss-20b spend part of this on hidden reasoning, so an empty reply with finish_reason 'length' means you should raise it to a few hundred or more.": "Maximum number of tokens to generate in the reply, for example 512. Reasoning models such as openai/gpt-oss-20b spend part of this on hidden reasoning, so an empty reply with finish_reason 'length' means you should raise it to a few hundred or more.", + "Sampling temperature between 0 and 2. 0 is the most deterministic. Leave empty for the model default.": "Sampling temperature between 0 and 2. 0 is the most deterministic. Leave empty for the model default.", + "Nucleus sampling probability mass between 0 and 1, for example 0.9. Leave empty for the model default.": "Nucleus sampling probability mass between 0 and 1, for example 0.9. Leave empty for the model default.", + "Up to 4 text sequences at which generation stops.": "Up to 4 text sequences at which generation stops.", + "Random seed for more repeatable sampling, where the provider supports it.": "Random seed for more repeatable sampling, where the provider supports it.", + "'json_object' forces valid JSON; 'json_schema' forces JSON matching the JSON Schema field. Defaults to plain text.": "'json_object' forces valid JSON; 'json_schema' forces JSON matching the JSON Schema field. Defaults to plain text.", + "Required when Response Format is json_schema: {\"name\": \"answer\", \"schema\": {...JSON Schema...}, \"strict\": true}. A bare JSON Schema object is also accepted.": "Required when Response Format is json_schema: {\"name\": \"answer\", \"schema\": {...JSON Schema...}, \"strict\": true}. A bare JSON Schema object is also accepted.", + "Feature-extraction model ID, for example 'BAAI/bge-small-en-v1.5' (384 dimensions) or 'intfloat/multilingual-e5-large'. Find others with Search Models (pipeline_tag 'feature-extraction', inference_provider 'all'); append ':' to pick the provider.": "Feature-extraction model ID, for example 'BAAI/bge-small-en-v1.5' (384 dimensions) or 'intfloat/multilingual-e5-large'. Find others with Search Models (pipeline_tag 'feature-extraction', inference_provider 'all'); append ':' to pick the provider.", + "The texts to embed. One text returns one embedding; several texts return one embedding each, in order.": "The texts to embed. One text returns one embedding; several texts return one embedding each, in order.", + "Scale each vector to unit length. Leave empty for the model default.": "Scale each vector to unit length. Leave empty for the model default.", + "Cut texts longer than the model's maximum input length instead of failing. Leave empty for the model default.": "Cut texts longer than the model's maximum input length instead of failing. Leave empty for the model default.", + "Which end of an over-long text to cut when truncating. Leave empty for the model default (right).": "Which end of an over-long text to cut when truncating. Leave empty for the model default (right).", + "Name of a prompt template defined in the model's Sentence Transformers config, for example 'query' or 'passage' for retrieval models. Leave empty when the model has none.": "Name of a prompt template defined in the model's Sentence Transformers config, for example 'query' or 'passage' for retrieval models. Leave empty when the model has none.", "impira/layoutlm-document-qa (Recommended)": "impira/layoutlm-document-qa (Recommended)", "microsoft/layoutlmv3-base": "microsoft/layoutlmv3-base", "nielsr/layoutlmv2-finetuned-docvqa": "nielsr/layoutlmv2-finetuned-docvqa", @@ -260,5 +455,50 @@ "💼 Business & Commerce": "💼 Business & Commerce", "📎 Upload File": "📎 Upload File", "🔗 Image URL": "🔗 Image URL", - "🌐 Comprehensive Report": "🌐 Comprehensive Report" + "🌐 Comprehensive Report": "🌐 Comprehensive Report", + "Trending": "Trending", + "Downloads": "Downloads", + "Likes": "Likes", + "Recently Created": "Recently Created", + "Recently Updated": "Recently Updated", + "Only gated": "Only gated", + "Only not gated": "Only not gated", + "All": "All", + "Models": "Models", + "Datasets": "Datasets", + "Spaces": "Spaces", + "Dataset": "Dataset", + "Space": "Space", + "Discussions": "Discussions", + "Pull Requests": "Pull Requests", + "Open": "Open", + "Closed": "Closed", + "Most Reactions": "Most Reactions", + "Publish Date": "Publish Date", + "Upvotes": "Upvotes", + "black-forest-labs": "black-forest-labs", + "cerebras": "cerebras", + "cohere": "cohere", + "fal-ai": "fal-ai", + "featherless-ai": "featherless-ai", + "fireworks-ai": "fireworks-ai", + "groq": "groq", + "hf-inference": "hf-inference", + "hyperbolic": "hyperbolic", + "nebius": "nebius", + "novita": "novita", + "nscale": "nscale", + "openai": "openai", + "ovhcloud": "ovhcloud", + "replicate": "replicate", + "sambanova": "sambanova", + "scaleway": "scaleway", + "together": "together", + "Auto (your preference order)": "Auto (your preference order)", + "Text": "Text", + "JSON Object": "JSON Object", + "Yes": "Yes", + "No": "No", + "Right (keep the start)": "Right (keep the start)", + "Left (keep the end)": "Left (keep the end)" } \ No newline at end of file diff --git a/packages/pieces/community/hugging-face/src/index.ts b/packages/pieces/community/hugging-face/src/index.ts index d9bc98fa1768..ce7e6b4cbe0b 100644 --- a/packages/pieces/community/hugging-face/src/index.ts +++ b/packages/pieces/community/hugging-face/src/index.ts @@ -1,4 +1,4 @@ -import { createPiece, PieceAuth } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { documentQuestionAnswering } from './lib/actions/document-question-answering'; import { languageTranslation } from './lib/actions/language-translation'; @@ -8,6 +8,35 @@ import { chatCompletion } from './lib/actions/chat-completion'; import { createImage } from './lib/actions/create-image'; import { objectDetection } from './lib/actions/object-detection'; import { imageClassification } from './lib/actions/image-classification'; +import { searchModels } from './lib/actions/search-models'; +import { searchDatasets } from './lib/actions/search-datasets'; +import { searchSpaces } from './lib/actions/search-spaces'; +import { getModel } from './lib/actions/get-model'; +import { getDataset } from './lib/actions/get-dataset'; +import { getSpace } from './lib/actions/get-space'; +import { listTrendingRepos } from './lib/actions/list-trending-repos'; +import { listHubTags } from './lib/actions/list-hub-tags'; +import { listRepoFiles } from './lib/actions/list-repo-files'; +import { getRepoPathsInfo } from './lib/actions/get-repo-paths-info'; +import { readRepoFile } from './lib/actions/read-repo-file'; +import { listRepoCommits } from './lib/actions/list-repo-commits'; +import { listRepoRefs } from './lib/actions/list-repo-refs'; +import { compareRepoRevisions } from './lib/actions/compare-repo-revisions'; +import { getRepoSize } from './lib/actions/get-repo-size'; +import { getRepoSecurityScan } from './lib/actions/get-repo-security-scan'; +import { getCurrentUser } from './lib/actions/get-current-user'; +import { getUserOverview } from './lib/actions/get-user-overview'; +import { getOrganizationOverview } from './lib/actions/get-organization-overview'; +import { listOrganizationMembers } from './lib/actions/list-organization-members'; +import { listDiscussions } from './lib/actions/list-discussions'; +import { getDiscussion } from './lib/actions/get-discussion'; +import { listDailyPapers } from './lib/actions/list-daily-papers'; +import { searchPapers } from './lib/actions/search-papers'; +import { getPaper } from './lib/actions/get-paper'; +import { listCollections } from './lib/actions/list-collections'; +import { getCollection } from './lib/actions/get-collection'; +import { generateChatCompletion } from './lib/actions/generate-chat-completion'; +import { generateEmbeddings } from './lib/actions/generate-embeddings'; import { huggingFaceAuth } from './lib/auth'; export const huggingface = createPiece({ @@ -15,7 +44,7 @@ export const huggingface = createPiece({ description: 'Run inference on 100,000+ open ML models for NLP, vision, and audio tasks', auth: huggingFaceAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/huggingface.svg', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], authors: ['Ani-4x'], @@ -28,6 +57,35 @@ export const huggingface = createPiece({ createImage, objectDetection, imageClassification, + searchModels, + searchDatasets, + searchSpaces, + getModel, + getDataset, + getSpace, + listTrendingRepos, + listHubTags, + listRepoFiles, + getRepoPathsInfo, + readRepoFile, + listRepoCommits, + listRepoRefs, + compareRepoRevisions, + getRepoSize, + getRepoSecurityScan, + getCurrentUser, + getUserOverview, + getOrganizationOverview, + listOrganizationMembers, + listDiscussions, + getDiscussion, + listDailyPapers, + searchPapers, + getPaper, + listCollections, + getCollection, + generateChatCompletion, + generateEmbeddings, ], triggers: [], }); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts b/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts index 0d1d962f298d..cd2fdea852b0 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/chat-completion.ts @@ -6,7 +6,7 @@ import { huggingFaceAuth } from '../auth'; import { chatCompletionOutputSchema } from '../output-schemas'; export const chatCompletion = createAction({ - audience: 'both', + audience: 'human', name: 'chat_completion', classification: 'READ', auth: huggingFaceAuth, diff --git a/packages/pieces/community/hugging-face/src/lib/actions/compare-repo-revisions.ts b/packages/pieces/community/hugging-face/src/lib/actions/compare-repo-revisions.ts new file mode 100644 index 000000000000..ea36c061c410 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/compare-repo-revisions.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { hfUtils } from '../common/utils'; +import { compareRepoRevisionsOutputSchema } from '../output-schemas'; + +const MAX_DIFF_CHARS = 100000; + +export const compareRepoRevisions = createAction({ + auth: huggingFaceAuth, + name: 'compare_repo_revisions', + classification: 'READ', + displayName: 'Compare Revisions', + description: 'Get the git diff between two revisions of a Hub repository.', + audience: 'ai', + aiMetadata: { + description: + "Returns the raw git diff between two revisions (branches, tags or commit ids) of a Hub repository, for example to see what a pull request ref changes against main. The diff is cut at 100000 characters and truncated is set when that happens; large binary or LFS changes appear only as pointer changes. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: compareRepoRevisionsOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + base: Property.ShortText({ + displayName: 'Base Revision', + description: "The older revision, for example 'main', 'v1.0' or a commit id.", + required: true, + }), + head: Property.ShortText({ + displayName: 'Head Revision', + description: "The newer revision, for example 'refs/pr/1' or a commit id.", + required: true, + }), + }, + async run(context) { + const { repo_type, repo_id, base, head } = context.propsValue; + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/compare/${hfRepo.encodeRevision(base)}..${hfRepo.encodeRevision(head)}`, + }); + const raw = typeof response.body === 'string' ? response.body : JSON.stringify(response.body ?? ''); + const diff = hfUtils.truncateText({ text: raw, maxChars: MAX_DIFF_CHARS }); + return { + base, + head, + diff: diff.text, + truncated: diff.truncated, + diff_length: diff.originalLength, + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/document-question-answering.ts b/packages/pieces/community/hugging-face/src/lib/actions/document-question-answering.ts index 05fae34970bc..50fd8cc703eb 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/document-question-answering.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/document-question-answering.ts @@ -15,7 +15,7 @@ export const documentQuestionAnswering = createAction({ 'Answer questions from document images using Hugging Face models', aiMetadata: { description: - 'Answers a natural-language question about an uploaded document image (invoice, contract, form) with a layout-aware extractive model such as LayoutLM, returning the matching text span and its confidence score. Choose it to pull one specific value out of a scanned document - use image_classification or object_detection for general-purpose vision, and chat_completion when the source text is already plain. Requires both a document image file and a question; read-only and idempotent, since it only analyses the supplied file.', + 'Answers a natural-language question about an uploaded document image (invoice, contract, form) with a layout-aware extractive model such as LayoutLM, returning the matching text span and its confidence score. Choose it to pull one specific value out of a scanned document - use image_classification or object_detection for general-purpose vision, and generate_chat_completion when the source text is already plain. Requires both a document image file and a question; read-only and idempotent, since it only analyses the supplied file.', idempotent: true, }, props: { diff --git a/packages/pieces/community/hugging-face/src/lib/actions/generate-chat-completion.ts b/packages/pieces/community/hugging-face/src/lib/actions/generate-chat-completion.ts new file mode 100644 index 000000000000..34a2f6bd1960 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/generate-chat-completion.ts @@ -0,0 +1,241 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { InferenceClient } from '@huggingface/inference'; +import type { ChatCompletionInput, ChatCompletionInputMessage } from '@huggingface/tasks'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfInference } from '../common/inference'; +import { hfUtils } from '../common/utils'; +import { generateChatCompletionOutputSchema } from '../output-schemas'; + +const DEFAULT_CHAT_MODEL = 'openai/gpt-oss-20b'; + +export const generateChatCompletion = createAction({ + auth: huggingFaceAuth, + name: 'generate_chat_completion', + classification: 'READ', + displayName: 'Generate Chat Completion', + description: 'Generate an assistant reply from a list of chat messages with a model served by Inference Providers.', + audience: 'ai', + aiMetadata: { + description: + "Runs one OpenAI-style chat completion on a Hugging Face model served by Inference Providers and returns the assistant reply, finish reason and token usage. Use it for open-ended generation, reasoning or structured JSON output (set Response Format); prefer text_summarization, language_translation or text_classification for those narrower tasks. Reasoning models such as openai/gpt-oss-20b spend part of max_tokens on hidden reasoning, so an empty content with finish_reason 'length' means max_tokens must be raised to a few hundred or more. Each call spends the account's Inference Providers credits (free monthly allowance, then pay-as-you-go) and sampled output varies between runs, so it is not idempotent.", + idempotent: false, + }, + outputSchema: generateChatCompletionOutputSchema, + props: { + model: Property.ShortText({ + displayName: 'Model', + description: + "Model ID served by Inference Providers, for example 'openai/gpt-oss-20b' or 'Qwen/Qwen3-8B'. Find others with Search Models (pipeline_tag 'text-generation', inference_provider 'all'); append ':' (for example ':groq') or ':preferred' to pick the provider. Leave empty to use openai/gpt-oss-20b.", + required: false, + defaultValue: DEFAULT_CHAT_MODEL, + }), + provider: hfInference.providerProp(), + messages: Property.Json({ + displayName: 'Messages', + description: + 'The conversation as a JSON array of {"role", "content"} objects, oldest first, for example [{"role": "user", "content": "Summarize this in one line: ..."}]. Roles are system, user and assistant. For vision models, content may be an array of parts such as {"type": "text", "text": "..."} and {"type": "image_url", "image_url": {"url": "https://..."}}.', + required: true, + }), + system_prompt: Property.LongText({ + displayName: 'System Prompt', + description: 'Optional instructions sent as a leading system message. Nothing is added when empty.', + required: false, + }), + max_tokens: Property.Number({ + displayName: 'Max Tokens', + description: + "Maximum number of tokens to generate in the reply, for example 512. Reasoning models such as openai/gpt-oss-20b spend part of this on hidden reasoning, so an empty reply with finish_reason 'length' means you should raise it to a few hundred or more.", + required: false, + }), + temperature: Property.Number({ + displayName: 'Temperature', + description: 'Sampling temperature between 0 and 2. 0 is the most deterministic. Leave empty for the model default.', + required: false, + }), + top_p: Property.Number({ + displayName: 'Top P', + description: 'Nucleus sampling probability mass between 0 and 1, for example 0.9. Leave empty for the model default.', + required: false, + }), + stop: Property.Array({ + displayName: 'Stop Sequences', + description: 'Up to 4 text sequences at which generation stops.', + required: false, + }), + seed: Property.Number({ + displayName: 'Seed', + description: 'Random seed for more repeatable sampling, where the provider supports it.', + required: false, + }), + response_format: Property.StaticDropdown({ + displayName: 'Response Format', + description: "'json_object' forces valid JSON; 'json_schema' forces JSON matching the JSON Schema field. Defaults to plain text.", + required: false, + options: { + disabled: false, + options: [ + { label: 'Text', value: 'text' }, + { label: 'JSON Object', value: 'json_object' }, + { label: 'JSON Schema', value: 'json_schema' }, + ], + }, + }), + json_schema: Property.Json({ + displayName: 'JSON Schema', + description: + 'Required when Response Format is json_schema: {"name": "answer", "schema": {...JSON Schema...}, "strict": true}. A bare JSON Schema object is also accepted.', + required: false, + }), + }, + async run(context) { + const { + model, + provider, + messages, + system_prompt, + max_tokens, + temperature, + top_p, + stop, + seed, + response_format, + json_schema, + } = context.propsValue; + const resolved = hfInference.resolveModelAndProvider({ model, provider, defaultModel: DEFAULT_CHAT_MODEL }); + const conversation = parseMessages(messages); + const systemText = system_prompt?.trim(); + const allMessages: ChatCompletionInputMessage[] = systemText + ? [{ role: 'system', content: systemText }, ...conversation] + : conversation; + const stopSequences = hfUtils.toStringArray(stop); + if (stopSequences.length > 4) { + throw new Error('Provide at most 4 stop sequences.'); + } + const args: ChatCompletionInput = { + model: resolved.model, + messages: allMessages, + stream: false, + ...(max_tokens !== undefined ? { max_tokens } : {}), + ...(temperature !== undefined ? { temperature } : {}), + ...(top_p !== undefined ? { top_p } : {}), + ...(seed !== undefined ? { seed } : {}), + ...(stopSequences.length > 0 ? { stop: stopSequences } : {}), + ...buildResponseFormat({ format: response_format, schema: json_schema }), + }; + const client = new InferenceClient(context.auth.secret_text); + try { + const result = await client.chatCompletion({ ...args, provider: resolved.provider }); + const choice = result.choices?.[0]; + return { + content: choice?.message?.content ?? null, + finish_reason: choice?.finish_reason ?? null, + model: result.model ?? resolved.model, + id: result.id ?? null, + usage: { + prompt_tokens: result.usage?.prompt_tokens ?? null, + completion_tokens: result.usage?.completion_tokens ?? null, + total_tokens: result.usage?.total_tokens ?? null, + }, + }; + } catch (error) { + throw hfInference.toError(error); + } + }, +}); + +function parseMessages(value: unknown): ChatCompletionInputMessage[] { + const raw: unknown = typeof value === 'string' ? parseJson(value) : value; + if (!Array.isArray(raw) || raw.length === 0) { + throw new Error('Messages must be a non-empty JSON array of {"role", "content"} objects.'); + } + return raw.map((item: unknown, index) => { + if (!hfHub.isRecord(item) || typeof item['role'] !== 'string') { + throw new Error(`Message ${index + 1} must be an object with a string "role".`); + } + const role = item['role']; + const content = item['content']; + if (typeof content === 'string') { + return { role, content }; + } + if (Array.isArray(content)) { + return { role, content: content.map((part: unknown) => parseContentPart({ part, index })) }; + } + throw new Error(`Message ${index + 1} needs "content" as a string or an array of content parts.`); + }); +} + +function parseContentPart({ part, index }: ParseContentPartParams): MessageChunk { + if (hfHub.isRecord(part) && part['type'] === 'text' && typeof part['text'] === 'string') { + return { type: 'text', text: part['text'] }; + } + if (hfHub.isRecord(part) && part['type'] === 'image_url') { + const imageUrl = part['image_url']; + const url = hfHub.isRecord(imageUrl) ? imageUrl['url'] : imageUrl; + if (typeof url === 'string') { + return { type: 'image_url', image_url: { url } }; + } + } + throw new Error( + `Message ${index + 1} has an invalid content part. Use {"type": "text", "text": "..."} or {"type": "image_url", "image_url": {"url": "..."}}.` + ); +} + +function buildResponseFormat({ format, schema }: BuildResponseFormatParams): { + response_format?: ResponseFormat; +} { + switch (format) { + case undefined: + case 'text': + return {}; + case 'json_object': + return { response_format: { type: 'json_object' } }; + case 'json_schema': { + const raw: unknown = typeof schema === 'string' ? parseJson(schema) : schema; + if (!hfHub.isRecord(raw)) { + throw new Error('JSON Schema is required when Response Format is json_schema.'); + } + const inner = raw['schema']; + const name = raw['name']; + const strict = raw['strict']; + if (hfHub.isRecord(inner)) { + return { + response_format: { + type: 'json_schema', + json_schema: { + name: typeof name === 'string' ? name : 'response', + schema: inner, + ...(typeof strict === 'boolean' ? { strict } : {}), + }, + }, + }; + } + return { response_format: { type: 'json_schema', json_schema: { name: 'response', schema: raw } } }; + } + default: + throw new Error(`Unknown Response Format '${format}'. Use text, json_object or json_schema.`); + } +} + +function parseJson(text: string): unknown { + try { + const parsed: unknown = JSON.parse(text); + return parsed; + } catch { + throw new Error('Expected valid JSON.'); + } +} + +type ParseContentPartParams = { + part: unknown; + index: number; +}; + +type BuildResponseFormatParams = { + format: string | undefined; + schema: unknown; +}; + +type ResponseFormat = NonNullable; + +type MessageChunk = Extract, unknown[]>[number]; diff --git a/packages/pieces/community/hugging-face/src/lib/actions/generate-embeddings.ts b/packages/pieces/community/hugging-face/src/lib/actions/generate-embeddings.ts new file mode 100644 index 000000000000..c443a39b155b --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/generate-embeddings.ts @@ -0,0 +1,147 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { InferenceClient } from '@huggingface/inference'; +import { huggingFaceAuth } from '../auth'; +import { hfInference } from '../common/inference'; +import { hfUtils } from '../common/utils'; +import { generateEmbeddingsOutputSchema } from '../output-schemas'; + +const DEFAULT_EMBEDDING_MODEL = 'BAAI/bge-small-en-v1.5'; + +export const generateEmbeddings = createAction({ + auth: huggingFaceAuth, + name: 'generate_embeddings', + classification: 'READ', + displayName: 'Generate Text Embeddings', + description: 'Turn text into embedding vectors with a feature-extraction model served by Inference Providers.', + audience: 'ai', + aiMetadata: { + description: + "Converts one or more texts into numeric embedding vectors with a Hugging Face feature-extraction model served by Inference Providers, for semantic search, clustering or similarity. One text returns a single embedding with its dimensions; several texts return one vector per text in the same order. Spends Inference Providers credits; the result is deterministic for a fixed model, so it is safe to retry.", + idempotent: true, + }, + outputSchema: generateEmbeddingsOutputSchema, + props: { + model: Property.ShortText({ + displayName: 'Model', + description: + "Feature-extraction model ID, for example 'BAAI/bge-small-en-v1.5' (384 dimensions) or 'intfloat/multilingual-e5-large'. Find others with Search Models (pipeline_tag 'feature-extraction', inference_provider 'all'); append ':' to pick the provider.", + required: true, + defaultValue: DEFAULT_EMBEDDING_MODEL, + }), + provider: hfInference.providerProp(), + inputs: Property.Array({ + displayName: 'Texts', + description: 'The texts to embed. One text returns one embedding; several texts return one embedding each, in order.', + required: true, + }), + normalize: hfInference.optionalBooleanProp({ + displayName: 'Normalize', + description: 'Scale each vector to unit length. Leave empty for the model default.', + }), + truncate: hfInference.optionalBooleanProp({ + displayName: 'Truncate', + description: "Cut texts longer than the model's maximum input length instead of failing. Leave empty for the model default.", + }), + truncation_direction: Property.StaticDropdown({ + displayName: 'Truncation Direction', + description: 'Which end of an over-long text to cut when truncating. Leave empty for the model default (right).', + required: false, + options: { + disabled: false, + options: [ + { label: 'Right (keep the start)', value: 'right' }, + { label: 'Left (keep the end)', value: 'left' }, + ], + }, + }), + prompt_name: Property.ShortText({ + displayName: 'Prompt Name', + description: + "Name of a prompt template defined in the model's Sentence Transformers config, for example 'query' or 'passage' for retrieval models. Leave empty when the model has none.", + required: false, + }), + }, + async run(context) { + const { model, provider, inputs, normalize, truncate, truncation_direction, prompt_name } = context.propsValue; + const raw: unknown = inputs; + const texts = typeof raw === 'string' ? hfUtils.toStringArray([raw]) : hfUtils.toStringArray(raw); + if (texts.length === 0) { + throw new Error('Provide at least one non-empty text to embed.'); + } + const resolved = hfInference.resolveModelAndProvider({ model, provider, defaultModel: DEFAULT_EMBEDDING_MODEL }); + const normalizeFlag = hfUtils.booleanFlag(normalize); + const truncateFlag = hfUtils.booleanFlag(truncate); + const direction = truncation_direction === 'left' || truncation_direction === 'right' ? truncation_direction : undefined; + const promptName = prompt_name?.trim(); + const client = new InferenceClient(context.auth.secret_text); + try { + const output = await client.featureExtraction({ + model: resolved.model, + provider: resolved.provider, + inputs: texts.length === 1 ? texts[0] : texts, + ...(normalizeFlag !== undefined ? { normalize: normalizeFlag } : {}), + ...(truncateFlag !== undefined ? { truncate: truncateFlag } : {}), + ...(direction ? { truncation_direction: direction } : {}), + ...(promptName ? { prompt_name: promptName } : {}), + }); + const embeddings = toPooledEmbeddings({ output, count: texts.length }); + if (texts.length === 1) { + const embedding = embeddings[0]; + return { model: resolved.model, embedding, dimensions: embedding.length }; + } + return { + model: resolved.model, + embeddings, + count: embeddings.length, + dimensions: embeddings[0]?.length ?? 0, + }; + } catch (error) { + throw hfInference.toError(error); + } + }, +}); + +function toPooledEmbeddings({ output, count }: ToPooledEmbeddingsParams): number[][] { + if (isNumberArray(output)) { + if (count === 1) { + return [output]; + } + throw new Error(NOT_ONE_PER_TEXT_MESSAGE); + } + if (!Array.isArray(output)) { + throw new Error(NOT_ONE_PER_TEXT_MESSAGE); + } + const items: unknown[] = output; + if (items.some(isNumberMatrix)) { + throw new Error(TOKEN_LEVEL_MESSAGE); + } + const rows = items.filter(isNumberArray); + if (rows.length !== items.length) { + throw new Error(NOT_ONE_PER_TEXT_MESSAGE); + } + if (count === 1 && rows.length > 1) { + throw new Error(TOKEN_LEVEL_MESSAGE); + } + if (rows.length !== count) { + throw new Error(NOT_ONE_PER_TEXT_MESSAGE); + } + return rows; +} + +function isNumberMatrix(value: unknown): value is number[][] { + return Array.isArray(value) && value.length > 0 && value.every(isNumberArray); +} + +function isNumberArray(value: unknown): value is number[] { + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'number'); +} + +const TOKEN_LEVEL_MESSAGE = + 'The model returned token-level vectors instead of one pooled embedding per text. Use a sentence-embedding model such as BAAI/bge-small-en-v1.5.'; +const NOT_ONE_PER_TEXT_MESSAGE = + 'The model did not return one pooled embedding per text. Use a sentence-embedding model such as BAAI/bge-small-en-v1.5.'; + +type ToPooledEmbeddingsParams = { + output: unknown; + count: number; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-collection.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-collection.ts new file mode 100644 index 000000000000..8841ba6ddad6 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-collection.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { getCollectionOutputSchema } from '../output-schemas'; + +export const getCollection = createAction({ + auth: huggingFaceAuth, + name: 'get_collection', + classification: 'READ', + displayName: 'Get Collection', + description: 'Get one Hugging Face collection with all of its items.', + audience: 'ai', + aiMetadata: { + description: + "Returns one Hugging Face collection by its slug: title, description, owner, visibility, upvotes and every item (models, datasets, Spaces, papers or nested collections) with its type, ID and note. Get the slug from List Collections or the collection URL. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getCollectionOutputSchema, + props: { + slug: Property.ShortText({ + displayName: 'Collection Slug', + description: + "The full collection slug in 'namespace/title-id' form, for example 'meta-llama/llama-32-66f448ffc8c32f949b04c8cf' (the part after huggingface.co/collections/).", + required: true, + }), + }, + async run(context) { + const segments = context.propsValue.slug + .trim() + .replace(/^https?:\/\/huggingface\.co\/collections\//, '') + .split('/') + .filter((segment) => segment.length > 0); + if (segments.length !== 2) { + throw new Error( + "Invalid collection slug. Use the 'namespace/title-id' form, for example 'meta-llama/llama-32-66f448ffc8c32f949b04c8cf'." + ); + } + const path = `/api/collections/${segments.map(encodeURIComponent).join('/')}`; + const token = context.auth.secret_text; + try { + const response = await hfHub.request({ token, method: HttpMethod.GET, path }); + return response.body; + } catch (error) { + try { + const response = await hfHub.request({ token, method: HttpMethod.GET, path, anonymous: true }); + return response.body; + } catch { + throw error; + } + } + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-current-user.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-current-user.ts new file mode 100644 index 000000000000..c78c9bbee4d7 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-current-user.ts @@ -0,0 +1,86 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { getCurrentUserOutputSchema } from '../output-schemas'; + +export const getCurrentUser = createAction({ + auth: huggingFaceAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User & Token', + description: 'Get the Hugging Face account behind the connection and the role of its access token.', + audience: 'ai', + aiMetadata: { + description: + "Returns the account behind the connected token (username, full name, email, PRO status, organizations with the user's role in each) and the token's name, role ('read', 'write' or 'fineGrained') and fine-grained permissions. Call it first to learn the username or organization namespaces, or to diagnose 401/403 errors: a 'read' token cannot perform Hub writes. Never returns the token value. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getCurrentUserOutputSchema, + props: {}, + async run(context) { + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/whoami-v2', + }); + const body = hfHub.isRecord(response.body) ? response.body : {}; + const accessToken = pickRecord({ source: pickRecord({ source: body, key: 'auth' }), key: 'accessToken' }); + const fineGrained = pickRecord({ source: accessToken, key: 'fineGrained' }); + const orgs = Array.isArray(body['orgs']) ? body['orgs'].filter(hfHub.isRecord) : []; + const scoped = Array.isArray(fineGrained?.['scoped']) ? fineGrained['scoped'].filter(hfHub.isRecord) : []; + return { + username: pickString({ source: body, key: 'name' }), + full_name: pickString({ source: body, key: 'fullname' }), + email: pickString({ source: body, key: 'email' }), + email_verified: pickBoolean({ source: body, key: 'emailVerified' }), + is_pro: pickBoolean({ source: body, key: 'isPro' }), + avatar_url: pickString({ source: body, key: 'avatarUrl' }), + account_type: pickString({ source: body, key: 'type' }), + organizations: orgs.map((org) => ({ + name: pickString({ source: org, key: 'name' }), + full_name: pickString({ source: org, key: 'fullname' }), + role_in_org: pickString({ source: org, key: 'roleInOrg' }), + is_enterprise: pickBoolean({ source: org, key: 'isEnterprise' }), + })), + token_name: pickString({ source: accessToken, key: 'displayName' }), + token_role: pickString({ source: accessToken, key: 'role' }), + token_created_at: pickString({ source: accessToken, key: 'createdAt' }), + token_can_read_gated_repos: pickBoolean({ source: fineGrained, key: 'canReadGatedRepos' }), + token_global_permissions: pickStringArray({ source: fineGrained, key: 'global' }), + token_scoped_permissions: scoped.map((scope) => { + const entity = pickRecord({ source: scope, key: 'entity' }); + return { + entity_type: pickString({ source: entity, key: 'type' }), + entity_name: pickString({ source: entity, key: 'name' }), + permissions: pickStringArray({ source: scope, key: 'permissions' }), + }; + }), + }; + }, +}); + +function pickRecord({ source, key }: PickParams): Record | null { + const value = source?.[key]; + return hfHub.isRecord(value) ? value : null; +} + +function pickString({ source, key }: PickParams): string | null { + const value = source?.[key]; + return typeof value === 'string' ? value : null; +} + +function pickBoolean({ source, key }: PickParams): boolean | null { + const value = source?.[key]; + return typeof value === 'boolean' ? value : null; +} + +function pickStringArray({ source, key }: PickParams): string[] { + const value = source?.[key]; + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} + +type PickParams = { + source: Record | null; + key: string; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-dataset.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-dataset.ts new file mode 100644 index 000000000000..7d38bf5c9b6c --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-dataset.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getDatasetOutputSchema } from '../output-schemas'; + +export const getDataset = createAction({ + auth: huggingFaceAuth, + name: 'get_dataset', + classification: 'READ', + displayName: 'Get Dataset', + description: 'Get the Hub metadata of one dataset repository.', + audience: 'ai', + aiMetadata: { + description: + "Returns the Hub repository metadata of one dataset: card data, tags, file list (siblings), gating, downloads and likes, optionally at a specific revision. Use it after Search Datasets to inspect a dataset repository; its README or other text files can then be read with Read Repo File. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getDatasetOutputSchema, + props: { + repo_id: hfProps.repoId(), + revision: hfProps.optionalRevision(), + }, + async run(context) { + const { repo_id, revision } = context.propsValue; + const revisionPath = revision?.trim() ? `/revision/${hfRepo.encodeRevision(revision)}` : ''; + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/datasets/${hfRepo.encodeRepoId(repo_id)}${revisionPath}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-discussion.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-discussion.ts new file mode 100644 index 000000000000..3a16537fe734 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-discussion.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getDiscussionOutputSchema } from '../output-schemas'; + +export const getDiscussion = createAction({ + auth: huggingFaceAuth, + name: 'get_discussion', + classification: 'READ', + displayName: 'Get Discussion or PR', + description: 'Get one discussion or pull request of a Hub repository, with its comments and events.', + audience: 'ai', + aiMetadata: { + description: + "Returns one discussion or pull request of a Hub repository by its per-repository number: title, status, author, every comment and status/title event and, for pull requests, the diff URL, target branch and merge info. Get the number from List Discussions & PRs. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getDiscussionOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + discussion_number: Property.Number({ + displayName: 'Discussion Number', + description: "The discussion or pull request number within the repository, for example 12 (the 'num' from List Discussions & PRs).", + required: true, + }), + }, + async run(context) { + const { repo_type, repo_id, discussion_number } = context.propsValue; + if (!Number.isInteger(discussion_number) || discussion_number < 1) { + throw new Error('Discussion Number must be a positive whole number.'); + } + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/discussions/${discussion_number}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-model.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-model.ts new file mode 100644 index 000000000000..2b07d2fdd280 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-model.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getModelOutputSchema } from '../output-schemas'; + +export const getModel = createAction({ + auth: huggingFaceAuth, + name: 'get_model', + classification: 'READ', + displayName: 'Get Model', + description: 'Get the Hub metadata of one model repository.', + audience: 'ai', + aiMetadata: { + description: + "Returns the Hub metadata of one model: card data, tags, pipeline_tag, library, file list (siblings), gating, downloads and likes, optionally at a specific revision. Use it after Search Models to inspect a model before running it or reading its files with Read Repo File. Legacy un-namespaced IDs such as 'gpt2' are followed to their canonical owner. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getModelOutputSchema, + props: { + repo_id: hfProps.repoId(), + revision: hfProps.optionalRevision(), + }, + async run(context) { + const { repo_id, revision } = context.propsValue; + const revisionPath = revision?.trim() ? `/revision/${hfRepo.encodeRevision(revision)}` : ''; + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/models/${hfRepo.encodeRepoId(repo_id)}${revisionPath}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-organization-overview.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-organization-overview.ts new file mode 100644 index 000000000000..422e1751edd9 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-organization-overview.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { getOrganizationOverviewOutputSchema } from '../output-schemas'; + +export const getOrganizationOverview = createAction({ + auth: huggingFaceAuth, + name: 'get_organization_overview', + classification: 'READ', + displayName: 'Get Organization Profile', + description: 'Get the public profile of a Hugging Face organization.', + audience: 'ai', + aiMetadata: { + description: + "Returns the public profile of a Hugging Face organization: full name, avatar URL, verification and Enterprise status, follower and member counts, and how many models, datasets and Spaces it owns. Use List Organization Members for its members and Get User Profile for an individual user. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getOrganizationOverviewOutputSchema, + props: { + organization: Property.ShortText({ + displayName: 'Organization', + description: "The organization's Hub name, for example 'huggingface' or 'meta-llama'.", + required: true, + }), + }, + async run(context) { + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/organizations/${encodeURIComponent(context.propsValue.organization.trim())}/overview`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-paper.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-paper.ts new file mode 100644 index 000000000000..0fcc41a6f1ce --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-paper.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfUtils } from '../common/utils'; +import { getPaperOutputSchema } from '../output-schemas'; + +export const getPaper = createAction({ + auth: huggingFaceAuth, + name: 'get_paper', + classification: 'READ', + displayName: 'Get Paper', + description: 'Get the details of one research paper on Hugging Face.', + audience: 'ai', + aiMetadata: { + description: + "Returns one research paper on Hugging Face Papers by its arXiv ID: title, authors, summary, publish date, upvotes and linked resources, plus its discussion comments (with comment IDs) when Include Comments is on. Get the ID from Search Papers or List Daily Papers. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getPaperOutputSchema, + props: { + paper_id: Property.ShortText({ + displayName: 'Paper ID', + description: "The paper's arXiv ID, for example '2307.09288'.", + required: true, + }), + include_comments: Property.Checkbox({ + displayName: 'Include Comments', + description: 'Also return the comments on the paper page, with their comment IDs.', + required: false, + defaultValue: false, + }), + include_submission_deadline: Property.Checkbox({ + displayName: 'Include Submission Deadline', + description: 'Also return the conference submission deadline linked to the paper, when there is one.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { paper_id, include_comments, include_submission_deadline } = context.propsValue; + const paperId = paper_id.trim(); + if (paperId.length === 0) { + throw new Error('Paper ID is required.'); + } + const fields = hfUtils.toStringArray([ + include_comments ? 'comments' : '', + include_submission_deadline ? 'submissionDeadline' : '', + ]); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/papers/${encodeURIComponent(paperId)}`, + query: fields.map((field): [string, string] => ['field', field]), + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-repo-paths-info.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-paths-info.ts new file mode 100644 index 000000000000..4e2829a1aa89 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-paths-info.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { hfUtils } from '../common/utils'; +import { getRepoPathsInfoOutputSchema } from '../output-schemas'; + +export const getRepoPathsInfo = createAction({ + auth: huggingFaceAuth, + name: 'get_repo_paths_info', + classification: 'READ', + displayName: 'Get File/Folder Info', + description: 'Get size, LFS and last-commit details for specific files or folders in a repository.', + audience: 'ai', + aiMetadata: { + description: + 'Returns details for up to 2000 known paths in a Hub repository at a revision: type (file or directory), size, blob id, LFS info and, with Include Last Commit, the last commit and security status. Use it when you already know the paths (from List Repo Files) and need their sizes, for example to check a file fits the Read Repo File limit. Read-only and safe to retry; paths that do not exist are simply left out.', + idempotent: true, + }, + outputSchema: getRepoPathsInfoOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + revision: hfProps.revision(), + paths: Property.Array({ + displayName: 'Paths', + description: "File or folder paths inside the repository, for example 'config.json' or 'onnx/model.onnx'. Up to 2000.", + required: true, + }), + expand: Property.Checkbox({ + displayName: 'Include Last Commit', + description: 'Add the last commit and security-scan status for each path.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { repo_type, repo_id, revision, paths, expand } = context.propsValue; + const pathList = hfUtils.toStringArray(paths); + if (pathList.length === 0 || pathList.length > 2000) { + throw new Error('Provide between 1 and 2000 paths.'); + } + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.POST, + path: `${apiPath}/paths-info/${hfRepo.encodeRevision(revision)}`, + body: { paths: pathList, expand: expand === true }, + }); + const entries = Array.isArray(response.body) ? response.body : []; + return { paths: entries, count: entries.length }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-repo-security-scan.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-security-scan.ts new file mode 100644 index 000000000000..77a161e9adf8 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-security-scan.ts @@ -0,0 +1,37 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getRepoSecurityScanOutputSchema } from '../output-schemas'; + +export const getRepoSecurityScan = createAction({ + auth: huggingFaceAuth, + name: 'get_repo_security_scan', + classification: 'READ', + displayName: 'Get Security Scan', + description: 'Get the malware and pickle security-scan status of a repository.', + audience: 'ai', + aiMetadata: { + description: + "Returns the Hub's malware and pickle-import security scan status for a repository: whether scans are done and which files were flagged, each with a level such as safe, caution, suspicious or unsafe. Use it as a safety check before recommending that someone download or load a model's weights. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getRepoSecurityScanOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + }, + async run(context) { + const { repo_type, repo_id } = context.propsValue; + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/scan`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-repo-size.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-size.ts new file mode 100644 index 000000000000..3fbbca204e91 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-repo-size.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getRepoSizeOutputSchema } from '../output-schemas'; + +export const getRepoSize = createAction({ + auth: huggingFaceAuth, + name: 'get_repo_size', + classification: 'READ', + displayName: 'Get Repo Size', + description: 'Get the total size in bytes of a repository, or of one folder in it.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the total size in bytes of every file under a path of a Hub repository at a revision (the whole repository when the path is empty), counting LFS files at their real size. Use it to judge download cost before fetching a model; for per-file sizes use List Repo Files or Get File/Folder Info. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getRepoSizeOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + revision: hfProps.revision(), + path: Property.ShortText({ + displayName: 'Folder Path', + description: "Folder to measure, for example 'onnx'. Leave empty for the whole repository.", + required: false, + }), + }, + async run(context) { + const { repo_type, repo_id, revision, path } = context.propsValue; + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const encodedPath = hfRepo.encodeFilePath(path); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/treesize/${hfRepo.encodeRevision(revision)}${encodedPath ? `/${encodedPath}` : ''}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-space.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-space.ts new file mode 100644 index 000000000000..566e59988fe4 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-space.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { getSpaceOutputSchema } from '../output-schemas'; + +export const getSpace = createAction({ + auth: huggingFaceAuth, + name: 'get_space', + classification: 'READ', + displayName: 'Get Space', + description: 'Get the Hub metadata of one Space.', + audience: 'ai', + aiMetadata: { + description: + "Returns the Hub metadata of one Space: its SDK, runtime stage (for example RUNNING, BUILDING, SLEEPING or RUNTIME_ERROR), hardware, file list, tags and likes, optionally at a specific revision. Use it after Search Spaces to check whether a Space is up and how it is built. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getSpaceOutputSchema, + props: { + repo_id: hfProps.repoId(), + revision: hfProps.optionalRevision(), + }, + async run(context) { + const { repo_id, revision } = context.propsValue; + const revisionPath = revision?.trim() ? `/revision/${hfRepo.encodeRevision(revision)}` : ''; + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/spaces/${hfRepo.encodeRepoId(repo_id)}${revisionPath}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/get-user-overview.ts b/packages/pieces/community/hugging-face/src/lib/actions/get-user-overview.ts new file mode 100644 index 000000000000..e3128ebb415c --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/get-user-overview.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { getUserOverviewOutputSchema } from '../output-schemas'; + +export const getUserOverview = createAction({ + auth: huggingFaceAuth, + name: 'get_user_overview', + classification: 'READ', + displayName: 'Get User Profile', + description: 'Get the public profile of a Hugging Face user.', + audience: 'ai', + aiMetadata: { + description: + "Returns the public profile of a Hugging Face user: full name, avatar URL, PRO status, organizations, follower counts and how many models, datasets and Spaces they own. Use Get Current User & Token for the connected account itself and Get Organization Profile for an organization. Social handles are not included. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: getUserOverviewOutputSchema, + props: { + username: Property.ShortText({ + displayName: 'Username', + description: "The user's Hub username, for example 'julien-c'.", + required: true, + }), + }, + async run(context) { + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/users/${encodeURIComponent(context.propsValue.username.trim())}/overview`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts b/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts index a2a0ddf4bcc9..f2a889d14559 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/language-translation.ts @@ -14,7 +14,7 @@ export const languageTranslation = createAction({ 'Translate text between languages using specialized Hugging Face translation models', aiMetadata: { description: - 'Translates a block of text with a dedicated Hugging Face machine-translation model, chosen either from the dropdown of Helsinki-NLP opus-mt language pairs or by passing any translation model ID in the custom-model field, which takes precedence over the dropdown. Use it instead of chat_completion whenever the task is purely translation, since these models are narrower and more consistent; the source and target language codes apply only to multilingual models, because a single-pair model already fixes the direction. Read-only and idempotent: it returns the translation and stores nothing.', + 'Translates a block of text with a dedicated Hugging Face machine-translation model, chosen either from the dropdown of Helsinki-NLP opus-mt language pairs or by passing any translation model ID in the custom-model field, which takes precedence over the dropdown. Use it instead of generate_chat_completion whenever the task is purely translation, since these models are narrower and more consistent; the source and target language codes apply only to multilingual models, because a single-pair model already fixes the direction. Read-only and idempotent: it returns the translation and stores nothing.', idempotent: true, }, outputSchema: languageTranslationOutputSchema, diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-collections.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-collections.ts new file mode 100644 index 000000000000..94b9baa37fe6 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-collections.ts @@ -0,0 +1,78 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { listCollectionsOutputSchema } from '../output-schemas'; + +export const listCollections = createAction({ + auth: huggingFaceAuth, + name: 'list_collections', + classification: 'SEARCH', + displayName: 'List Collections', + description: 'List or search curated collections on the Hugging Face Hub.', + audience: 'ai', + aiMetadata: { + description: + "Lists Hugging Face collections (curated groups of models, datasets, Spaces and papers), filterable by owner, contained item and text, one page per call with a next_cursor. Each entry carries its slug ('namespace/title-id'), which Get Collection needs to return every item. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listCollectionsOutputSchema, + props: { + owner: Property.ShortText({ + displayName: 'Owner', + description: "Only collections owned by this user or organization, for example 'huggingface'.", + required: false, + }), + item: Property.ShortText({ + displayName: 'Contains Item', + description: + "Only collections that contain this item, written as '/', for example 'models/openai-community/gpt2', 'datasets/stanfordnlp/imdb' or 'papers/2307.09288'.", + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only collections whose title matches this text.', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Sort order. Defaults to trending.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Trending', value: 'trending' }, + { label: 'Upvotes', value: 'upvotes' }, + { label: 'Recently Updated', value: 'lastModified' }, + ], + }, + }), + limit: hfProps.limit({ defaultValue: 10, max: 100 }), + cursor: hfProps.cursor(), + }, + async run(context) { + const { owner, item, search, sort, limit, cursor } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 100, name: 'Limit' }); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/collections', + query: [ + ['owner', owner], + ['item', item], + ['q', search], + ['sort', sort], + ['limit', limit], + ['cursor', cursor], + ], + }); + const collections = Array.isArray(response.body) ? response.body : []; + return { + collections, + count: collections.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-daily-papers.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-daily-papers.ts new file mode 100644 index 000000000000..d21a965e5107 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-daily-papers.ts @@ -0,0 +1,91 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { listDailyPapersOutputSchema } from '../output-schemas'; + +export const listDailyPapers = createAction({ + auth: huggingFaceAuth, + name: 'list_daily_papers', + classification: 'SEARCH', + displayName: 'List Daily Papers', + description: "List the research papers featured on Hugging Face's Daily Papers page.", + audience: 'ai', + aiMetadata: { + description: + "Lists the research papers featured on Hugging Face Daily Papers for one day, ISO week or month (or the latest when none is given), sorted by publish date or trending, one page per call with next_page. Set at most one of Date, Week or Month. Use Search Papers to find papers by topic and Get Paper for one paper's details. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listDailyPapersOutputSchema, + props: { + date: Property.ShortText({ + displayName: 'Date', + description: "Papers featured on this day, in 'YYYY-MM-DD' form, for example '2026-09-22'.", + required: false, + }), + week: Property.ShortText({ + displayName: 'Week', + description: "Papers featured in this ISO week, in 'YYYY-Www' form, for example '2026-W38'.", + required: false, + }), + month: Property.ShortText({ + displayName: 'Month', + description: "Papers featured in this month, in 'YYYY-MM' form, for example '2026-09'.", + required: false, + }), + submitter: Property.ShortText({ + displayName: 'Submitter', + description: 'Only papers submitted by this Hub username.', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Sort order. Defaults to publish date.', + required: false, + defaultValue: 'publishedAt', + options: { + disabled: false, + options: [ + { label: 'Publish Date', value: 'publishedAt' }, + { label: 'Trending', value: 'trending' }, + ], + }, + }), + limit: hfProps.limit({ defaultValue: 50, max: 100 }), + page: hfProps.page(), + }, + async run(context) { + const { date, week, month, submitter, sort, limit, page } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 100, name: 'Limit' }); + hfUtils.assertLimit({ value: page, min: 0, max: Number.MAX_SAFE_INTEGER, name: 'Page' }); + const periods = [date, week, month].filter((value) => value !== undefined && value.trim().length > 0); + if (periods.length > 1) { + throw new Error('Set at most one of Date, Week or Month.'); + } + const pageNumber = page ?? 0; + const pageSize = limit ?? 50; + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/daily_papers', + query: [ + ['date', date?.trim()], + ['week', week?.trim()], + ['month', month?.trim()], + ['submitter', submitter], + ['sort', sort], + ['limit', pageSize], + ['p', pageNumber], + ], + }); + const papers = Array.isArray(response.body) ? response.body : []; + return { + papers, + count: papers.length, + page: pageNumber, + next_page: papers.length >= pageSize ? pageNumber + 1 : null, + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-discussions.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-discussions.ts new file mode 100644 index 000000000000..aefd76b718b8 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-discussions.ts @@ -0,0 +1,112 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { hfUtils } from '../common/utils'; +import { listDiscussionsOutputSchema } from '../output-schemas'; + +export const listDiscussions = createAction({ + auth: huggingFaceAuth, + name: 'list_discussions', + classification: 'SEARCH', + displayName: 'List Discussions & PRs', + description: 'List the discussions and pull requests of a Hub repository.', + audience: 'ai', + aiMetadata: { + description: + "Lists the discussions and pull requests of a model, dataset or Space repository, one page per call (page-number pagination with next_page), filterable by kind, status, author and text search. Each entry carries its per-repository number, which Get Discussion or PR needs. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listDiscussionsOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + kind: Property.StaticDropdown({ + displayName: 'Kind', + description: 'Only discussions, only pull requests, or both. Defaults to both.', + required: false, + defaultValue: 'all', + options: { + disabled: false, + options: [ + { label: 'All', value: 'all' }, + { label: 'Discussions', value: 'discussion' }, + { label: 'Pull Requests', value: 'pull_request' }, + ], + }, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Only open, only closed, or both. Defaults to both.', + required: false, + defaultValue: 'all', + options: { + disabled: false, + options: [ + { label: 'All', value: 'all' }, + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }), + author: Property.ShortText({ + displayName: 'Author', + description: "Only threads opened by this Hub username, for example 'julien-c'.", + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only threads whose title matches this text.', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Sort order. Defaults to most recently created.', + required: false, + defaultValue: 'recently-created', + options: { + disabled: false, + options: [ + { label: 'Recently Created', value: 'recently-created' }, + { label: 'Trending', value: 'trending' }, + { label: 'Most Reactions', value: 'reactions' }, + ], + }, + }), + page: hfProps.page(), + }, + async run(context) { + const { repo_type, repo_id, kind, status, author, search, sort, page } = context.propsValue; + hfUtils.assertLimit({ value: page, min: 0, max: Number.MAX_SAFE_INTEGER, name: 'Page' }); + const token = context.auth.secret_text; + const pageNumber = page ?? 0; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/discussions`, + query: [ + ['p', pageNumber], + ['type', kind], + ['status', status], + ['author', author], + ['search', search], + ['sort', sort], + ], + }); + const body = hfHub.isRecord(response.body) ? response.body : {}; + const discussions = Array.isArray(body['discussions']) ? body['discussions'] : []; + const totalCount = typeof body['count'] === 'number' ? body['count'] : null; + const start = typeof body['start'] === 'number' ? body['start'] : 0; + const hasMore = totalCount !== null && start + discussions.length < totalCount; + return { + discussions, + count: discussions.length, + total_count: totalCount, + page: pageNumber, + next_page: hasMore ? pageNumber + 1 : null, + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-hub-tags.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-hub-tags.ts new file mode 100644 index 000000000000..66abd9c1e61d --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-hub-tags.ts @@ -0,0 +1,88 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { listHubTagsOutputSchema } from '../output-schemas'; + +const MODEL_TAG_TYPES = [ + 'pipeline_tag', + 'library', + 'dataset', + 'language', + 'license', + 'arxiv', + 'doi', + 'region', + 'deploy', + 'other', +]; + +const DATASET_TAG_TYPES = [ + 'benchmark', + 'task_categories', + 'task_ids', + 'size_categories', + 'modality', + 'format', + 'library', + 'language', + 'license', + 'arxiv', + 'doi', + 'region', + 'annotations_creators', + 'language_creators', + 'multilinguality', + 'source_datasets', + 'other', +]; + +export const listHubTags = createAction({ + auth: huggingFaceAuth, + name: 'list_hub_tags', + classification: 'SEARCH', + displayName: 'List Hub Tags by Type', + description: 'List the tags available for filtering models or datasets, grouped by tag type.', + audience: 'ai', + aiMetadata: { + description: + "Lists the Hub's valid filter tags for models or datasets, grouped by tag type (for models: pipeline_tag, library, language, license and more; for datasets: task_categories, size_categories, modality, format and more). Call it to find the exact 'filter' values to pass to Search Models or Search Datasets. Spaces have no tag catalogue. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listHubTagsOutputSchema, + props: { + repo_type: Property.StaticDropdown({ + displayName: 'Repository Type', + description: "Whose tags to list: 'model' or 'dataset'.", + required: true, + defaultValue: 'model', + options: { + disabled: false, + options: [ + { label: 'Model', value: 'model' }, + { label: 'Dataset', value: 'dataset' }, + ], + }, + }), + tag_type: Property.ShortText({ + displayName: 'Tag Type', + description: `Only tags of this type. Models: ${MODEL_TAG_TYPES.join(', ')}. Datasets: ${DATASET_TAG_TYPES.join(', ')}. Leave empty for every type (large).`, + required: false, + }), + }, + async run(context) { + const { repo_type, tag_type } = context.propsValue; + const allowed = repo_type === 'dataset' ? DATASET_TAG_TYPES : MODEL_TAG_TYPES; + const tagType = tag_type?.trim(); + if (tagType && !allowed.includes(tagType)) { + throw new Error(`Unknown tag type '${tagType}' for ${repo_type}s. Use one of: ${allowed.join(', ')}.`); + } + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: repo_type === 'dataset' ? '/api/datasets-tags-by-type' : '/api/models-tags-by-type', + query: [['type', tagType]], + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-organization-members.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-organization-members.ts new file mode 100644 index 000000000000..7b8765be507c --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-organization-members.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { listOrganizationMembersOutputSchema } from '../output-schemas'; + +export const listOrganizationMembers = createAction({ + auth: huggingFaceAuth, + name: 'list_organization_members', + classification: 'SEARCH', + displayName: 'List Organization Members', + description: 'List the members of a Hugging Face organization.', + audience: 'ai', + aiMetadata: { + description: + "Lists the members of a Hugging Face organization, one page per call with a next_cursor; each member has a username, full name, avatar and, where visible, their org role. Optionally narrow by a name search. The Email filter and member emails are only available to organization admins; other tokens should leave Email empty. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listOrganizationMembersOutputSchema, + props: { + organization: Property.ShortText({ + displayName: 'Organization', + description: "The organization's Hub name, for example 'huggingface'.", + required: true, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only members whose username or full name matches this text.', + required: false, + }), + email: Property.ShortText({ + displayName: 'Email', + description: 'Only the member with this email address. Organization admins only.', + required: false, + }), + limit: hfProps.limit({ defaultValue: 100, max: 10000 }), + cursor: hfProps.cursor(), + }, + async run(context) { + const { organization, search, email, limit, cursor } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 10, max: 10000, name: 'Limit' }); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: `/api/organizations/${encodeURIComponent(organization.trim())}/members`, + query: [ + ['search', search], + ['email', email], + ['limit', limit], + ['cursor', cursor], + ], + }); + const members = Array.isArray(response.body) ? response.body : []; + return { + members, + count: members.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-repo-commits.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-commits.ts new file mode 100644 index 000000000000..6bc877a30770 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-commits.ts @@ -0,0 +1,57 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { hfUtils } from '../common/utils'; +import { listRepoCommitsOutputSchema } from '../output-schemas'; + +export const listRepoCommits = createAction({ + auth: huggingFaceAuth, + name: 'list_repo_commits', + classification: 'SEARCH', + displayName: 'List Repo Commits', + description: 'List the commit history of a branch or revision in a Hub repository.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the commit history of a Hub repository revision, newest first, one page per call (page-number pagination with next_page); each commit carries its id, title, message, authors and date. Use commit ids as revisions for Compare Revisions or Read Repo File; use List Branches & Tags to find branch names. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listRepoCommitsOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + revision: hfProps.revision(), + page: hfProps.page(), + limit: hfProps.limit({ defaultValue: 50, max: 1000 }), + }, + async run(context) { + const { repo_type, repo_id, revision, page, limit } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 1000, name: 'Limit' }); + hfUtils.assertLimit({ value: page, min: 0, max: Number.MAX_SAFE_INTEGER, name: 'Page' }); + const token = context.auth.secret_text; + const pageNumber = page ?? 0; + const pageSize = limit ?? 50; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/commits/${hfRepo.encodeRevision(revision)}`, + query: [ + ['p', pageNumber], + ['limit', pageSize], + ], + }); + const commits = Array.isArray(response.body) ? response.body : []; + const linkedPage = hfHub.parseNextLinkParam({ headers: response.headers, param: 'p' }); + const nextPage = linkedPage !== null ? Number(linkedPage) : commits.length >= pageSize ? pageNumber + 1 : null; + return { + commits, + count: commits.length, + page: pageNumber, + next_page: nextPage, + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-repo-files.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-files.ts new file mode 100644 index 000000000000..8b8dd48cf47e --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-files.ts @@ -0,0 +1,75 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { hfUtils } from '../common/utils'; +import { listRepoFilesOutputSchema } from '../output-schemas'; + +export const listRepoFiles = createAction({ + auth: huggingFaceAuth, + name: 'list_repo_files', + classification: 'SEARCH', + displayName: 'List Repo Files', + description: 'List the files and folders of a model, dataset or Space repository.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the files and folders under a path of a Hub repository at a revision, one page per call with a next_cursor; each entry carries its type, path, size and LFS info. Use it to discover file paths before Read Repo File or Get File/Folder Info; set Recursive to walk sub-folders. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listRepoFilesOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + revision: hfProps.revision(), + path: Property.ShortText({ + displayName: 'Folder Path', + description: "Folder inside the repository to list, for example 'onnx' or 'data/train'. Leave empty for the root.", + required: false, + }), + recursive: Property.Checkbox({ + displayName: 'Recursive', + description: 'List every file in sub-folders too.', + required: false, + defaultValue: false, + }), + expand: Property.Checkbox({ + displayName: 'Include Last Commit', + description: 'Add the last commit and security-scan status to each entry. Limits a page to 100 entries.', + required: false, + defaultValue: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum entries in this page: up to 1000, or up to 100 when Include Last Commit is on.', + required: false, + }), + cursor: hfProps.cursor(), + }, + async run(context) { + const { repo_type, repo_id, revision, path, recursive, expand, limit, cursor } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: expand ? 100 : 1000, name: 'Limit' }); + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const encodedPath = hfRepo.encodeFilePath(path); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/tree/${hfRepo.encodeRevision(revision)}${encodedPath ? `/${encodedPath}` : ''}`, + query: [ + ['recursive', recursive ? 'true' : undefined], + ['expand', expand ? 'true' : undefined], + ['limit', limit], + ['cursor', cursor], + ], + }); + const files = Array.isArray(response.body) ? response.body : []; + return { + files, + count: files.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-repo-refs.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-refs.ts new file mode 100644 index 000000000000..60c20768033d --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-repo-refs.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { listRepoRefsOutputSchema } from '../output-schemas'; + +export const listRepoRefs = createAction({ + auth: huggingFaceAuth, + name: 'list_repo_refs', + classification: 'SEARCH', + displayName: 'List Branches & Tags', + description: 'List the branches, tags and converted refs of a Hub repository.', + audience: 'ai', + aiMetadata: { + description: + "Lists every git ref of a Hub repository: branches, tags, converted refs (such as 'refs/convert/parquet') and, optionally, pull-request refs ('refs/pr/N'), each with its target commit. Use it to find a valid revision before reading files, listing commits or comparing revisions. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: listRepoRefsOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + include_prs: Property.Checkbox({ + displayName: 'Include Pull Request Refs', + description: "Also list the 'refs/pr/N' refs of open and closed pull requests.", + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { repo_type, repo_id, include_prs } = context.propsValue; + const token = context.auth.secret_text; + const apiPath = await hfRepo.apiPath({ token, repoType: repo_type, repoId: repo_id }); + const response = await hfHub.request({ + token, + method: HttpMethod.GET, + path: `${apiPath}/refs`, + query: [['include_prs', include_prs ? '1' : undefined]], + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/list-trending-repos.ts b/packages/pieces/community/hugging-face/src/lib/actions/list-trending-repos.ts new file mode 100644 index 000000000000..e376fe3e0306 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/list-trending-repos.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfUtils } from '../common/utils'; +import { listTrendingReposOutputSchema } from '../output-schemas'; + +export const listTrendingRepos = createAction({ + auth: huggingFaceAuth, + name: 'list_trending_repos', + classification: 'SEARCH', + displayName: 'List Trending Repos', + description: 'List the repositories currently trending on the Hugging Face Hub.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the models, datasets and Spaces currently trending on the Hugging Face Hub (up to 20), each tagged with its repo_type. Use it to answer "what is popular right now"; for keyword or tag search use Search Models, Search Datasets or Search Spaces instead. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listTrendingReposOutputSchema, + props: { + type: Property.StaticDropdown({ + displayName: 'Repository Type', + description: 'Only trending repositories of this type. Defaults to all types.', + required: false, + defaultValue: 'all', + options: { + disabled: false, + options: [ + { label: 'All', value: 'all' }, + { label: 'Models', value: 'model' }, + { label: 'Datasets', value: 'dataset' }, + { label: 'Spaces', value: 'space' }, + ], + }, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Number of trending repositories to return (1 to 20). Defaults to 10.', + required: false, + defaultValue: 10, + }), + }, + async run(context) { + const { type, limit } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 20, name: 'Limit' }); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/trending', + query: [ + ['type', type], + ['limit', limit], + ], + }); + const body = response.body; + const items = hfHub.isRecord(body) && Array.isArray(body['recentlyTrending']) ? body['recentlyTrending'] : []; + const repos = items.filter(hfHub.isRecord).map((item) => { + const repoData = hfHub.isRecord(item['repoData']) ? item['repoData'] : {}; + return { repo_type: item['repoType'] ?? null, ...repoData }; + }); + return { repos, count: repos.length }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/read-repo-file.ts b/packages/pieces/community/hugging-face/src/lib/actions/read-repo-file.ts new file mode 100644 index 000000000000..da37f27a6217 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/read-repo-file.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfRepo } from '../common/repo'; +import { readRepoFileOutputSchema } from '../output-schemas'; + +export const readRepoFile = createAction({ + auth: huggingFaceAuth, + name: 'read_repo_file', + classification: 'READ', + displayName: 'Read Repo File', + description: 'Read a text file (such as README.md or config.json) from a Hub repository.', + audience: 'ai', + aiMetadata: { + description: + 'Downloads one UTF-8 text file from a model, dataset or Space repository at a revision and returns its content, for example README.md, config.json or a training script. Files over 1 MiB or binary files (weights, images, parquet) fail with FILE_TOO_LARGE or BINARY_FILE and the download URL instead; check sizes first with Get File/Folder Info. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: readRepoFileOutputSchema, + props: { + repo_type: hfProps.repoType(), + repo_id: hfProps.repoId(), + path: Property.ShortText({ + displayName: 'File Path', + description: "Path of the file inside the repository, for example 'README.md' or 'configs/config.json'.", + required: true, + }), + revision: hfProps.revision(), + }, + async run(context) { + const { repo_type, repo_id, path, revision } = context.propsValue; + const encodedPath = hfRepo.encodeFilePath(path); + if (!encodedPath) { + throw new Error('File Path is required.'); + } + const token = context.auth.secret_text; + const { resolvePrefix } = hfRepo.getTypeInfo(repo_type); + const canonicalId = await hfRepo.resolveRepoId({ token, repoType: repo_type, repoId: repo_id }); + const resolvedRevision = revision?.trim() || 'main'; + const url = `${hfHub.baseUrl}/${resolvePrefix}${hfRepo.encodeRepoId(canonicalId)}/resolve/${hfRepo.encodeRevision(resolvedRevision)}/${encodedPath}`; + const file = await hfHub.readRepoFileText({ token, url }); + return { + repo_type, + repo_id: canonicalId, + revision: resolvedRevision, + path, + content: file.content, + size_bytes: file.sizeBytes, + url, + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/search-datasets.ts b/packages/pieces/community/hugging-face/src/lib/actions/search-datasets.ts new file mode 100644 index 000000000000..ebb3b4af0001 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/search-datasets.ts @@ -0,0 +1,100 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { searchDatasetsOutputSchema } from '../output-schemas'; + +export const searchDatasets = createAction({ + auth: huggingFaceAuth, + name: 'search_datasets', + classification: 'SEARCH', + displayName: 'Search Datasets', + description: 'Search and filter datasets on the Hugging Face Hub.', + audience: 'ai', + aiMetadata: { + description: + "Searches Hugging Face Hub datasets by free text, author, tag filters and gating, returning one page of dataset summaries plus a next_cursor. Task filtering goes through Tag Filters (for example 'task_categories:text-classification'), not a pipeline field. Use Get Dataset for one dataset's metadata and Search Models or Search Spaces for other repo types. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: searchDatasetsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: "Free-text match on the dataset ID, for example 'imdb' or 'squad'.", + required: false, + }), + author: Property.ShortText({ + displayName: 'Author', + description: "Only datasets owned by this user or organization, for example 'stanfordnlp'.", + required: false, + }), + filter: Property.Array({ + displayName: 'Tag Filters', + description: + "Hub tags every result must carry, for example 'task_categories:text-classification', 'language:en' or 'size_categories:10K({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/datasets', + query: [ + ['search', search], + ['author', author], + ...hfUtils.toStringArray(filter).map((tag): [string, string] => ['filter', tag]), + ['gated', hfUtils.booleanFlag(gated)], + ['sort', sort], + ['limit', limit], + ['full', full ? 'true' : undefined], + ['cursor', cursor], + ], + }); + const datasets = Array.isArray(response.body) ? response.body : []; + return { + datasets, + count: datasets.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/search-models.ts b/packages/pieces/community/hugging-face/src/lib/actions/search-models.ts new file mode 100644 index 000000000000..58b08a7ae057 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/search-models.ts @@ -0,0 +1,101 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { searchModelsOutputSchema } from '../output-schemas'; + +export const searchModels = createAction({ + auth: huggingFaceAuth, + name: 'search_models', + classification: 'SEARCH', + displayName: 'Search Models', + description: 'Search and filter models on the Hugging Face Hub.', + audience: 'ai', + aiMetadata: { + description: + "Searches the Hugging Face Hub model catalogue by free text, author, tag filters, pipeline task and Inference Provider availability, returning one page of model summaries plus a next_cursor. Use it to find a model ID before calling Get Model, Generate Chat Completion (pipeline_tag 'text-generation', inference_provider 'all') or Generate Text Embeddings (pipeline_tag 'feature-extraction'); use Search Datasets or Search Spaces for other repo types. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: searchModelsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: "Free-text match on the model ID, for example 'llama' or 'bert-base'.", + required: false, + }), + author: Property.ShortText({ + displayName: 'Author', + description: "Only models owned by this user or organization, for example 'meta-llama'.", + required: false, + }), + filter: Property.Array({ + displayName: 'Tag Filters', + description: + "Hub tags every result must carry, for example 'text-classification', 'pytorch', 'license:apache-2.0' or 'language:en'. Discover valid values with List Hub Tags by Type.", + required: false, + }), + pipeline_tag: Property.ShortText({ + displayName: 'Pipeline Task', + description: "Only models for this task, for example 'text-generation', 'feature-extraction' or 'image-classification'.", + required: false, + }), + inference_provider: Property.ShortText({ + displayName: 'Inference Provider', + description: + "Only models served by Inference Providers: 'all' for any provider, or one provider name such as 'groq', 'together' or 'hf-inference'.", + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Sort order (always descending). Defaults to trending.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Trending', value: 'trendingScore' }, + { label: 'Downloads', value: 'downloads' }, + { label: 'Likes', value: 'likes' }, + { label: 'Recently Created', value: 'createdAt' }, + { label: 'Recently Updated', value: 'lastModified' }, + ], + }, + }), + limit: hfProps.limit({ defaultValue: 20, max: 1000 }), + full: Property.Checkbox({ + displayName: 'Include Full Details', + description: 'Return extra fields such as the file list and last-modified date for each model.', + required: false, + defaultValue: false, + }), + cursor: hfProps.cursor(), + }, + async run(context) { + const { search, author, filter, pipeline_tag, inference_provider, sort, limit, full, cursor } = + context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 1000, name: 'Limit' }); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/models', + query: [ + ['search', search], + ['author', author], + ...hfUtils.toStringArray(filter).map((tag): [string, string] => ['filter', tag]), + ['pipeline_tag', pipeline_tag], + ['inference_provider', inference_provider], + ['sort', sort], + ['limit', limit], + ['full', full ? 'true' : undefined], + ['cursor', cursor], + ], + }); + const models = Array.isArray(response.body) ? response.body : []; + return { + models, + count: models.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/search-papers.ts b/packages/pieces/community/hugging-face/src/lib/actions/search-papers.ts new file mode 100644 index 000000000000..bbbdca7f56b2 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/search-papers.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { searchPapersOutputSchema } from '../output-schemas'; + +export const searchPapers = createAction({ + auth: huggingFaceAuth, + name: 'search_papers', + classification: 'SEARCH', + displayName: 'Search Papers', + description: 'Search research papers indexed on Hugging Face.', + audience: 'ai', + aiMetadata: { + description: + "Searches the research papers indexed on Hugging Face Papers with a hybrid semantic and full-text query, returning up to 120 matches with their arXiv-style IDs, titles, authors and summaries. Use it to find papers on a topic; use List Daily Papers for what was featured on a given day and Get Paper for one paper's full details. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: searchPapersOutputSchema, + props: { + query: Property.ShortText({ + displayName: 'Query', + description: "What to search for, up to 250 characters, for example 'retrieval augmented generation'.", + required: true, + }), + limit: hfProps.limit({ defaultValue: 20, max: 120 }), + }, + async run(context) { + const { query, limit } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 120, name: 'Limit' }); + const trimmed = query.trim(); + if (trimmed.length === 0 || trimmed.length > 250) { + throw new Error('Query must be between 1 and 250 characters.'); + } + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/papers/search', + query: [ + ['q', trimmed], + ['limit', limit], + ], + }); + const papers = Array.isArray(response.body) ? response.body : []; + return { papers, count: papers.length }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/search-spaces.ts b/packages/pieces/community/hugging-face/src/lib/actions/search-spaces.ts new file mode 100644 index 000000000000..023f37aa9a8f --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/actions/search-spaces.ts @@ -0,0 +1,85 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { huggingFaceAuth } from '../auth'; +import { hfHub } from '../common/hub-client'; +import { hfProps } from '../common/props'; +import { hfUtils } from '../common/utils'; +import { searchSpacesOutputSchema } from '../output-schemas'; + +export const searchSpaces = createAction({ + auth: huggingFaceAuth, + name: 'search_spaces', + classification: 'SEARCH', + displayName: 'Search Spaces', + description: 'Search and filter Spaces (hosted ML apps) on the Hugging Face Hub.', + audience: 'ai', + aiMetadata: { + description: + "Searches Hugging Face Spaces (hosted Gradio, Docker or static ML apps) by free text, author and tag filters, returning one page of Space summaries plus a next_cursor. Use Get Space for one Space's SDK, hardware and runtime stage; use Search Models or Search Datasets for other repo types. Read-only and safe to retry.", + idempotent: true, + }, + outputSchema: searchSpacesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: "Free-text match on the Space ID, for example 'whisper' or 'chatbot'.", + required: false, + }), + author: Property.ShortText({ + displayName: 'Author', + description: "Only Spaces owned by this user or organization, for example 'huggingface'.", + required: false, + }), + filter: Property.Array({ + displayName: 'Tag Filters', + description: "Hub tags every result must carry, for example 'gradio', 'docker' or 'region:us'.", + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Sort order (always descending). Defaults to trending.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Trending', value: 'trendingScore' }, + { label: 'Likes', value: 'likes' }, + { label: 'Recently Created', value: 'createdAt' }, + { label: 'Recently Updated', value: 'lastModified' }, + ], + }, + }), + limit: hfProps.limit({ defaultValue: 20, max: 1000 }), + full: Property.Checkbox({ + displayName: 'Include Full Details', + description: 'Return extra fields such as the card data and file list for each Space.', + required: false, + defaultValue: false, + }), + cursor: hfProps.cursor(), + }, + async run(context) { + const { search, author, filter, sort, limit, full, cursor } = context.propsValue; + hfUtils.assertLimit({ value: limit, min: 1, max: 1000, name: 'Limit' }); + const response = await hfHub.request({ + token: context.auth.secret_text, + method: HttpMethod.GET, + path: '/api/spaces', + query: [ + ['search', search], + ['author', author], + ...hfUtils.toStringArray(filter).map((tag): [string, string] => ['filter', tag]), + ['sort', sort], + ['limit', limit], + ['full', full ? 'true' : undefined], + ['cursor', cursor], + ], + }); + const spaces = Array.isArray(response.body) ? response.body : []; + return { + spaces, + count: spaces.length, + next_cursor: hfHub.parseNextCursor(response.headers), + }; + }, +}); diff --git a/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts b/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts index dd26364dd0f3..2728ae406eef 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/text-classification.ts @@ -18,7 +18,7 @@ export const textClassification = createAction({ 'Classify text into categories using Hugging Face models - includes zero-shot classification for custom categories', aiMetadata: { description: - 'Scores a block of text against a set of labels and returns the ranked predictions, in one of three modes: zero-shot classifies into custom comma-separated categories you supply, pre-trained uses a curated sentiment, emotion, or topic model with its own fixed label set, and search runs any classification model looked up on the Hugging Face hub. Choose it to sort text into a known label set - use chat_completion for open-ended reasoning about the text, text_summarization to condense it, and image_classification when the input is an image rather than text. Zero-shot mode fails without at least one category; read-only and idempotent, as classifying stores nothing.', + 'Scores a block of text against a set of labels and returns the ranked predictions, in one of three modes: zero-shot classifies into custom comma-separated categories you supply, pre-trained uses a curated sentiment, emotion, or topic model with its own fixed label set, and search runs any classification model looked up on the Hugging Face hub. Choose it to sort text into a known label set - use generate_chat_completion for open-ended reasoning about the text, text_summarization to condense it, and image_classification when the input is an image rather than text. Zero-shot mode fails without at least one category; read-only and idempotent, as classifying stores nothing.', idempotent: true, }, outputSchema: textClassificationOutputSchema, diff --git a/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts b/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts index 13ba67996bed..c6778b9a1519 100644 --- a/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts +++ b/packages/pieces/community/hugging-face/src/lib/actions/text-summarization.ts @@ -14,7 +14,7 @@ export const textSummarization = createAction({ 'Generate abstractive summaries of long text using Hugging Face models - optimized for business content', aiMetadata: { description: - 'Condenses one long block of text into a shorter abstractive summary with a Hugging Face summarization model such as BART or Pegasus, targeting either a brief, medium, or detailed length preset or explicit min and max token counts. Choose it over chat_completion when the task is purely summarization, since it decodes greedily for stable output; use text_classification to label the text instead, or language_translation to change its language. Works best on inputs of roughly 512 to 1024 tokens and needs a truncation strategy for anything longer; read-only and idempotent, as it stores nothing.', + 'Condenses one long block of text into a shorter abstractive summary with a Hugging Face summarization model such as BART or Pegasus, targeting either a brief, medium, or detailed length preset or explicit min and max token counts. Choose it over generate_chat_completion when the task is purely summarization, since it decodes greedily for stable output; use text_classification to label the text instead, or language_translation to change its language. Works best on inputs of roughly 512 to 1024 tokens and needs a truncation strategy for anything longer; read-only and idempotent, as it stores nothing.', idempotent: true, }, outputSchema: textSummarizationOutputSchema, diff --git a/packages/pieces/community/hugging-face/src/lib/common/hub-client.ts b/packages/pieces/community/hugging-face/src/lib/common/hub-client.ts new file mode 100644 index 000000000000..c96102e2dd50 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/common/hub-client.ts @@ -0,0 +1,279 @@ +import { Readable } from 'node:stream'; +import { + AuthenticationType, + httpClient, + HttpError, + HttpHeaders, + HttpMethod, + HttpResponse, +} from '@activepieces/pieces-common'; + +function buildUrl({ baseUrl, path, query }: BuildUrlParams): string { + const search = new URLSearchParams(); + for (const [key, value] of query ?? []) { + if (value === undefined || value === null || value === '') { + continue; + } + search.append(key, String(value)); + } + const queryString = search.toString(); + return queryString ? `${baseUrl}${path}?${queryString}` : `${baseUrl}${path}`; +} + +async function hubRequest({ + token, + method, + path, + query, + body, + baseUrl, + anonymous, +}: HubRequestParams): Promise> { + const url = buildUrl({ baseUrl: baseUrl ?? HUB_BASE_URL, path, query }); + try { + return await httpClient.sendRequest({ + method, + url, + body, + authentication: anonymous ? undefined : { type: AuthenticationType.BEARER_TOKEN, token }, + }); + } catch (error) { + throw toHubError({ error, resource: path }); + } +} + +function extractErrorDetail(body: unknown): string | null { + if (typeof body === 'string') { + const trimmed = body.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 500) : null; + } + if (isRecord(body)) { + const error = body['error']; + if (typeof error === 'string') { + return error; + } + const message = body['message']; + if (typeof message === 'string') { + return message; + } + } + return null; +} + +function rateLimitError(detail: string | null): Error { + const match = detail?.match(/t=(\d+)/); + const resetHint = match + ? `Retry after about ${match[1]} seconds.` + : 'Hub limits reset on fixed 5-minute windows, so retry in a few minutes.'; + return new Error( + `Hugging Face rate limit reached (429). ${resetHint}${detail ? ` Details: ${detail}` : ''}` + ); +} + +function toHubError({ error, resource }: ToHubErrorParams): Error { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error : new Error(String(error)); + } + const status = error.response.status; + const detail = extractErrorDetail(error.response.body); + const suffix = detail ? ` Details: ${detail}` : ''; + switch (status) { + case 400: + case 422: + return new Error(`Hugging Face rejected the request (${status}) for ${resource}.${suffix}`); + case 401: + return new Error( + `Hugging Face rejected the access token (401) for ${resource}. Check that the connection's token is valid and has not been revoked; private or gated repositories also answer 401 when the token cannot see them.${suffix}` + ); + case 403: + return new Error( + `Access denied (403) for ${resource}. The token lacks permission for this resource (a fine-grained token may not cover it), or the repository is gated and this account has not been granted access.${suffix}` + ); + case 404: + return new Error( + `Not found (404): ${resource}. Check the repository type, ID, revision and path; private repositories are only visible to tokens that can access them.${suffix}` + ); + case 429: + return rateLimitError(detail); + default: + return new Error(`Hugging Face request failed (${status}) for ${resource}.${suffix}`); + } +} + +function readHeader({ headers, name }: ReadHeaderParams): string | null { + const value = headers?.[name.toLowerCase()]; + if (value === undefined) { + return null; + } + return Array.isArray(value) ? value.join(', ') : value; +} + +function parseNextLinkParam({ headers, param }: ParseNextLinkParams): string | null { + const link = readHeader({ headers, name: 'link' }); + if (!link) { + return null; + } + const match = link.match(/<([^>]+)>\s*;\s*rel="?next"?/); + if (!match) { + return null; + } + return new URL(match[1], HUB_BASE_URL).searchParams.get(param); +} + +function parseNextCursor(headers: HttpHeaders | undefined): string | null { + return parseNextLinkParam({ headers, param: 'cursor' }); +} + +async function readStreamWithCap({ stream, maxBytes }: ReadStreamParams): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of stream) { + const buffer: Buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > maxBytes) { + stream.destroy(); + return null; + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +function oversizeError({ url, size }: { url: string; size: number | null }): Error { + const sizeText = size === null ? `more than ${MAX_TEXT_FILE_BYTES} bytes` : `${size} bytes`; + return new Error( + `FILE_TOO_LARGE: the file is ${sizeText}, above the ${MAX_TEXT_FILE_BYTES}-byte limit for Read Repo File. Download it directly instead: ${url}` + ); +} + +function binaryError(url: string): Error { + return new Error( + `BINARY_FILE: the file is not UTF-8 text, so Read Repo File cannot return it. Download it directly instead: ${url}` + ); +} + +function decodeText(buffer: Buffer): string | null { + if (buffer.subarray(0, 8000).includes(0)) { + return null; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch { + return null; + } +} + +async function readRepoFileText({ token, url }: ReadRepoFileParams): Promise { + let currentUrl = url; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const sendAuth = new URL(currentUrl).host === HUB_HOST; + let response: HttpResponse; + try { + response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: currentUrl, + followRedirects: false, + responseType: 'stream', + authentication: sendAuth ? { type: AuthenticationType.BEARER_TOKEN, token } : undefined, + }); + } catch (error) { + throw toHubError({ error, resource: new URL(currentUrl).pathname }); + } + if (response.status >= 300 && response.status < 400) { + response.body.destroy(); + const linkedSize = Number(readHeader({ headers: response.headers, name: 'x-linked-size' })); + if (Number.isFinite(linkedSize) && linkedSize > MAX_TEXT_FILE_BYTES) { + throw oversizeError({ url, size: linkedSize }); + } + const location = readHeader({ headers: response.headers, name: 'location' }); + if (!location) { + throw new Error(`Hugging Face answered ${response.status} without a redirect target for ${url}`); + } + currentUrl = new URL(location, currentUrl).toString(); + continue; + } + const declaredLength = Number(readHeader({ headers: response.headers, name: 'content-length' })); + if (Number.isFinite(declaredLength) && declaredLength > MAX_TEXT_FILE_BYTES) { + response.body.destroy(); + throw oversizeError({ url, size: declaredLength }); + } + const buffer = await readStreamWithCap({ stream: response.body, maxBytes: MAX_TEXT_FILE_BYTES }); + if (buffer === null) { + throw oversizeError({ url, size: null }); + } + const content = decodeText(buffer); + if (content === null) { + throw binaryError(url); + } + return { content, sizeBytes: buffer.length }; + } + throw new Error(`Too many redirects while downloading ${url}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const HUB_HOST = 'huggingface.co'; +const HUB_BASE_URL = `https://${HUB_HOST}`; +const MAX_TEXT_FILE_BYTES = 1024 * 1024; +const MAX_REDIRECTS = 5; + +export const hfHub = { + baseUrl: HUB_BASE_URL, + maxTextFileBytes: MAX_TEXT_FILE_BYTES, + request: hubRequest, + toError: toHubError, + parseNextCursor, + parseNextLinkParam, + readRepoFileText, + isRecord, +}; + +export type QueryEntry = [string, string | number | boolean | undefined | null]; + +type BuildUrlParams = { + baseUrl: string; + path: string; + query?: QueryEntry[]; +}; + +type HubRequestParams = { + token: string; + method: HttpMethod; + path: string; + query?: QueryEntry[]; + body?: unknown; + baseUrl?: string; + anonymous?: boolean; +}; + +type ToHubErrorParams = { + error: unknown; + resource: string; +}; + +type ReadHeaderParams = { + headers: HttpHeaders | undefined; + name: string; +}; + +type ParseNextLinkParams = { + headers: HttpHeaders | undefined; + param: string; +}; + +type ReadStreamParams = { + stream: Readable; + maxBytes: number; +}; + +type ReadRepoFileParams = { + token: string; + url: string; +}; + +type RepoFileText = { + content: string; + sizeBytes: number; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/common/inference.ts b/packages/pieces/community/hugging-face/src/lib/common/inference.ts new file mode 100644 index 000000000000..2461e6fabc9d --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/common/inference.ts @@ -0,0 +1,132 @@ +import { Property } from '@activepieces/pieces-framework'; +import { + InferenceClientHubApiError, + InferenceClientInputError, + InferenceClientProviderApiError, + InferenceProviderOrPolicy, + PROVIDERS_OR_POLICIES, +} from '@huggingface/inference'; + +function findProvider(value: string): InferenceProviderOrPolicy | undefined { + return PROVIDERS_OR_POLICIES.find((provider) => provider === value); +} + +function resolveModelAndProvider({ model, provider, defaultModel }: ResolveModelParams): ResolvedModel { + const trimmed = (model ?? '').trim() || (defaultModel ?? ''); + if (trimmed.length === 0) { + throw new Error('Model is required.'); + } + const selected = provider ? findProvider(provider) : undefined; + if (provider && !selected) { + throw new Error(`Unknown inference provider '${provider}'. Use one of: ${PROVIDERS_OR_POLICIES.join(', ')}.`); + } + const colon = trimmed.lastIndexOf(':'); + if (colon <= 0) { + return { model: trimmed, provider: selected }; + } + const baseModel = trimmed.slice(0, colon); + const suffix = trimmed.slice(colon + 1).trim(); + if (suffix === 'fastest' || suffix === 'cheapest') { + throw new Error( + `The ':${suffix}' routing policy is not supported by this action. Remove the suffix to use your provider preference order, or pick a provider explicitly.` + ); + } + const suffixProvider = suffix === 'preferred' ? 'auto' : findProvider(suffix); + if (!suffixProvider) { + throw new Error( + `Unknown provider suffix ':${suffix}' on model '${trimmed}'. Use one of: ${PROVIDERS_OR_POLICIES.join(', ')}, or ':preferred'.` + ); + } + if (selected && selected !== suffixProvider) { + throw new Error( + `The model suffix ':${suffix}' conflicts with the selected provider '${selected}'. Set only one of them.` + ); + } + return { model: baseModel, provider: suffixProvider }; +} + +function toInferenceError(error: unknown): Error { + if (error instanceof InferenceClientProviderApiError || error instanceof InferenceClientHubApiError) { + const status = error.httpResponse.status; + switch (status) { + case 401: + return new Error( + `Hugging Face rejected the token for inference (401). Use a valid token; fine-grained tokens need the "Make calls to Inference Providers" permission. ${error.message}` + ); + case 402: + return new Error( + `Inference Providers credits are exhausted (402). Add credits or a payment method in your Hugging Face billing settings. ${error.message}` + ); + case 403: + return new Error( + `Access denied for inference (403). The token lacks inference permission, or the model is gated and this account has not been granted access. ${error.message}` + ); + case 404: + return new Error( + `Model not found or not served by the chosen provider (404). Find a served model with Search Models (inference_provider 'all'). ${error.message}` + ); + case 429: + return new Error(`Inference rate limit reached (429). Retry in a few minutes. ${error.message}`); + default: + return new Error(`Inference request failed (${status}). ${error.message}`); + } + } + if (error instanceof InferenceClientInputError) { + return new Error(error.message); + } + return error instanceof Error ? error : new Error(String(error)); +} + +function providerProp() { + return Property.StaticDropdown({ + displayName: 'Provider', + description: + "Inference provider to run the model on. Leave empty (or 'auto') to use the first available provider in your Hugging Face provider preference order.", + required: false, + options: { + disabled: false, + options: PROVIDERS_OR_POLICIES.map((provider) => ({ + label: provider === 'auto' ? 'Auto (your preference order)' : provider, + value: provider, + })), + }, + }); +} + +function optionalBooleanProp({ displayName, description }: OptionalBooleanParams) { + return Property.StaticDropdown({ + displayName, + description, + required: false, + options: { + disabled: false, + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], + }, + }); +} + +export const hfInference = { + resolveModelAndProvider, + toError: toInferenceError, + providerProp, + optionalBooleanProp, +}; + +type ResolveModelParams = { + model: string | undefined; + provider: string | undefined; + defaultModel?: string; +}; + +type ResolvedModel = { + model: string; + provider: InferenceProviderOrPolicy | undefined; +}; + +type OptionalBooleanParams = { + displayName: string; + description: string; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/common/props.ts b/packages/pieces/community/hugging-face/src/lib/common/props.ts new file mode 100644 index 000000000000..09f8191f6344 --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/common/props.ts @@ -0,0 +1,88 @@ +import { Property } from '@activepieces/pieces-framework'; + +function repoType() { + return Property.StaticDropdown({ + displayName: 'Repository Type', + description: "The kind of Hub repository: 'model', 'dataset' or 'space'.", + required: true, + defaultValue: 'model', + options: { + disabled: false, + options: [ + { label: 'Model', value: 'model' }, + { label: 'Dataset', value: 'dataset' }, + { label: 'Space', value: 'space' }, + ], + }, + }); +} + +function repoId() { + return Property.ShortText({ + displayName: 'Repository ID', + description: + "The repository ID in 'namespace/name' form, for example 'openai-community/gpt2' or 'stanfordnlp/imdb'. Find it with Search Models, Search Datasets or Search Spaces.", + required: true, + }); +} + +function revision() { + return Property.ShortText({ + displayName: 'Revision', + description: + "Branch, tag, or commit SHA to read, for example 'main', 'v1.0' or 'refs/pr/1'. Defaults to 'main'. List available refs with List Branches & Tags.", + required: false, + defaultValue: 'main', + }); +} + +function optionalRevision() { + return Property.ShortText({ + displayName: 'Revision', + description: + "Optional branch, tag, or commit SHA, for example 'main' or 'refs/pr/1'. Leave empty for the default branch.", + required: false, + }); +} + +function cursor() { + return Property.ShortText({ + displayName: 'Cursor', + description: + "The 'next_cursor' value returned by a previous call, to fetch the next page. Leave empty for the first page.", + required: false, + }); +} + +function limit({ defaultValue, max }: LimitParams) { + return Property.Number({ + displayName: 'Limit', + description: `Maximum number of results in this page (1 to ${max}).`, + required: false, + defaultValue, + }); +} + +function page() { + return Property.Number({ + displayName: 'Page', + description: 'Zero-based page number. Use 0 for the first page, then the returned next_page value.', + required: false, + defaultValue: 0, + }); +} + +export const hfProps = { + repoType, + repoId, + revision, + optionalRevision, + cursor, + limit, + page, +}; + +type LimitParams = { + defaultValue: number; + max: number; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/common/repo.ts b/packages/pieces/community/hugging-face/src/lib/common/repo.ts new file mode 100644 index 000000000000..f885c8bc738f --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/common/repo.ts @@ -0,0 +1,93 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { hfHub } from './hub-client'; + +function getRepoTypeInfo(repoType: string): RepoTypeInfo { + switch (repoType) { + case 'model': + return { apiSegment: 'models', resolvePrefix: '', singular: 'model' }; + case 'dataset': + return { apiSegment: 'datasets', resolvePrefix: 'datasets/', singular: 'dataset' }; + case 'space': + return { apiSegment: 'spaces', resolvePrefix: 'spaces/', singular: 'space' }; + default: + throw new Error(`Unknown repository type '${repoType}'. Use one of: model, dataset, space.`); + } +} + +function splitRepoId(repoId: string): string[] { + const segments = repoId + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter((segment) => segment.length > 0); + if (segments.length < 1 || segments.length > 2) { + throw new Error( + `Invalid repository ID '${repoId}'. Use the 'namespace/name' form, for example 'openai-community/gpt2'.` + ); + } + return segments; +} + +function encodeRepoId(repoId: string): string { + return splitRepoId(repoId).map(encodeURIComponent).join('/'); +} + +function encodeRevision(revision: string | undefined): string { + const value = revision?.trim(); + return encodeURIComponent(value && value.length > 0 ? value : 'main'); +} + +function encodeFilePath(path: string | undefined): string { + return (path ?? '') + .split('/') + .filter((segment) => segment.length > 0) + .map(encodeURIComponent) + .join('/'); +} + +async function resolveRepoId({ token, repoType, repoId }: RepoRefParams): Promise { + const segments = splitRepoId(repoId); + if (segments.length === 2) { + return segments.join('/'); + } + const { apiSegment } = getRepoTypeInfo(repoType); + const response = await hfHub.request<{ id?: unknown }>({ + token, + method: HttpMethod.GET, + path: `/api/${apiSegment}/${encodeURIComponent(segments[0])}`, + }); + const canonical = response.body?.id; + if (typeof canonical !== 'string' || !canonical.includes('/')) { + throw new Error( + `Could not resolve the legacy repository ID '${repoId}' to its 'namespace/name' form. Pass the full ID instead.` + ); + } + return canonical; +} + +async function repoApiPath({ token, repoType, repoId }: RepoRefParams): Promise { + const { apiSegment } = getRepoTypeInfo(repoType); + const canonical = await resolveRepoId({ token, repoType, repoId }); + return `/api/${apiSegment}/${encodeRepoId(canonical)}`; +} + +export const hfRepo = { + getTypeInfo: getRepoTypeInfo, + encodeRepoId, + encodeRevision, + encodeFilePath, + resolveRepoId, + apiPath: repoApiPath, +}; + +export type RepoTypeInfo = { + apiSegment: 'models' | 'datasets' | 'spaces'; + resolvePrefix: '' | 'datasets/' | 'spaces/'; + singular: 'model' | 'dataset' | 'space'; +}; + +type RepoRefParams = { + token: string; + repoType: string; + repoId: string; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/common/utils.ts b/packages/pieces/community/hugging-face/src/lib/common/utils.ts new file mode 100644 index 000000000000..6bf781b6c10b --- /dev/null +++ b/packages/pieces/community/hugging-face/src/lib/common/utils.ts @@ -0,0 +1,60 @@ +function truncateText({ text, maxChars }: TruncateTextParams): TruncatedText { + if (text.length <= maxChars) { + return { text, truncated: false, originalLength: text.length }; + } + return { text: text.slice(0, maxChars), truncated: true, originalLength: text.length }; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .filter((item): item is string | number => typeof item === 'string' || typeof item === 'number') + .map((item) => String(item).trim()) + .filter((item) => item.length > 0); +} + +function assertLimit({ value, min, max, name }: AssertLimitParams): void { + if (value === undefined) { + return; + } + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`${name} must be a whole number between ${min} and ${max}.`); + } +} + +function booleanFlag(value: string | undefined): boolean | undefined { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return undefined; +} + +export const hfUtils = { + truncateText, + toStringArray, + assertLimit, + booleanFlag, +}; + +type TruncateTextParams = { + text: string; + maxChars: number; +}; + +type TruncatedText = { + text: string; + truncated: boolean; + originalLength: number; +}; + +type AssertLimitParams = { + value: number | undefined; + min: number; + max: number; + name: string; +}; diff --git a/packages/pieces/community/hugging-face/src/lib/output-schemas.ts b/packages/pieces/community/hugging-face/src/lib/output-schemas.ts index d76154dd6c07..681de61f7160 100644 --- a/packages/pieces/community/hugging-face/src/lib/output-schemas.ts +++ b/packages/pieces/community/hugging-face/src/lib/output-schemas.ts @@ -11,6 +11,175 @@ const scoredLabelItems: OutputSchemaField[] = [ { key: 'score', label: 'Score', format: 'number' }, ]; +const nextCursorField: OutputSchemaField = { + key: 'next_cursor', + label: 'Next Cursor', + description: 'Pass this to Cursor to fetch the next page. Null on the last page.', +}; + +const nextPageField: OutputSchemaField = { + key: 'next_page', + label: 'Next Page', + format: 'number', + description: 'Pass this to Page to fetch the next page. Null on the last page.', +}; + +const countField: OutputSchemaField = { key: 'count', label: 'Count', format: 'number' }; + +const pageField: OutputSchemaField = { key: 'page', label: 'Page', format: 'number' }; + +const gatedField: OutputSchemaField = { + key: 'gated', + label: 'Gated', + description: "false, or the gating mode such as 'auto' or 'manual'.", +}; + +const hubAccountFields: OutputSchemaField[] = [ + { key: 'name', label: 'Username' }, + { key: 'fullname', label: 'Full Name' }, + { key: 'type', label: 'Account Type' }, +]; + +const repoSearchCoreFields: OutputSchemaField[] = [ + { key: 'id', label: 'Repo ID' }, + { key: 'likes', label: 'Likes', format: 'number' }, + { key: 'trendingScore', label: 'Trending Score', format: 'number' }, + { key: 'private', label: 'Private', format: 'boolean' }, + { key: 'tags', label: 'Tags' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, +]; + +const repoCardBaseFields: OutputSchemaField[] = [ + { key: 'id', label: 'Repo ID' }, + { key: 'author', label: 'Author' }, + { key: 'likes', label: 'Likes', format: 'number' }, + { key: 'downloads', label: 'Downloads', format: 'number' }, + { key: 'lastModified', label: 'Last Modified', format: 'datetime' }, + gatedField, + { key: 'private', label: 'Private', format: 'boolean' }, +]; + +const repoCardFields: OutputSchemaField[] = [ + ...repoCardBaseFields, + { key: 'pipeline_tag', label: 'Pipeline Task', description: 'Models only.' }, + { key: 'numParameters', label: 'Parameters', format: 'number', description: 'Models only.' }, +]; + +const repoDetailCoreFields: OutputSchemaField[] = [ + { key: 'id', label: 'Repo ID' }, + { key: 'author', label: 'Author' }, + { key: 'tags', label: 'Tags' }, + { key: 'likes', label: 'Likes', format: 'number' }, + { key: 'sha', label: 'Latest Commit SHA' }, + { key: 'lastModified', label: 'Last Modified', format: 'datetime' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'private', label: 'Private', format: 'boolean' }, + gatedField, + { key: 'disabled', label: 'Disabled', format: 'boolean' }, + { key: 'usedStorage', label: 'Used Storage', format: 'filesize' }, +]; + +const siblingsField: OutputSchemaField = { + key: 'siblings', + label: 'Files', + labelKey: 'rfilename', + listItems: [{ key: 'rfilename', label: 'File Path' }], +}; + +const gitRefItemFields: OutputSchemaField[] = [ + { key: 'name', label: 'Name' }, + { key: 'ref', label: 'Ref' }, + { key: 'targetCommit', label: 'Target Commit' }, +]; + +const discussionSummaryFields: OutputSchemaField[] = [ + { key: 'num', label: 'Number', format: 'number' }, + { key: 'title', label: 'Title' }, + { key: 'status', label: 'Status' }, + { key: 'isPullRequest', label: 'Is Pull Request', format: 'boolean' }, + { key: 'pinned', label: 'Pinned', format: 'boolean' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'author', label: 'Author', children: hubAccountFields }, + { + key: 'repo', + label: 'Repo', + children: [ + { key: 'name', label: 'Repo ID' }, + { key: 'type', label: 'Repo Type' }, + ], + }, +]; + +const commentEventFields: OutputSchemaField[] = [ + { key: 'id', label: 'Event ID' }, + { key: 'type', label: 'Event Type' }, + { key: 'createdAt', label: 'Created At', format: 'datetime' }, + { key: 'author', label: 'Author', children: hubAccountFields }, + { + key: 'data', + label: 'Data', + description: "For 'comment' events: the latest comment text and edit state.", + children: [ + { + key: 'latest', + label: 'Latest Version', + children: [ + { key: 'raw', label: 'Text (Markdown)' }, + { key: 'html', label: 'Text (HTML)', format: 'html' }, + { key: 'updatedAt', label: 'Updated At', format: 'datetime' }, + ], + }, + { key: 'edited', label: 'Edited', format: 'boolean' }, + { key: 'hidden', label: 'Hidden', format: 'boolean' }, + { key: 'numEdits', label: 'Edit Count', format: 'number' }, + ], + }, +]; + +const paperAuthorsField: OutputSchemaField = { + key: 'authors', + label: 'Authors', + labelKey: 'name', + listItems: [{ key: 'name', label: 'Name' }], +}; + +const paperCoreFields: OutputSchemaField[] = [ + { key: 'id', label: 'Paper ID', description: 'The arXiv ID, for example 2307.09288.' }, + { key: 'title', label: 'Title' }, + { key: 'summary', label: 'Abstract' }, + { key: 'publishedAt', label: 'Published At', format: 'datetime' }, + { key: 'upvotes', label: 'Upvotes', format: 'number' }, + paperAuthorsField, + { key: 'discussionId', label: 'Discussion ID' }, +]; + +const paperListingFields: OutputSchemaField[] = [ + { key: 'title', label: 'Title' }, + { key: 'publishedAt', label: 'Published At', format: 'datetime' }, + { key: 'thumbnail', label: 'Thumbnail', format: 'image' }, + { key: 'numComments', label: 'Comments', format: 'number' }, +]; + +const collectionFields: OutputSchemaField[] = [ + { key: 'slug', label: 'Collection Slug' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'lastUpdated', label: 'Last Updated', format: 'datetime' }, + { key: 'private', label: 'Private', format: 'boolean' }, + { key: 'upvotes', label: 'Upvotes', format: 'number' }, + { key: 'owner', label: 'Owner', children: hubAccountFields }, + { + key: 'items', + label: 'Items', + labelKey: 'id', + listItems: [ + { key: 'type', label: 'Item Type' }, + { key: 'position', label: 'Position', format: 'number' }, + ...repoCardFields, + ], + }, +]; + export const languageTranslationOutputSchema: OutputSchema = { fields: [ { key: 'translatedText', label: 'Translated Text' }, @@ -410,3 +579,583 @@ export const imageClassificationOutputSchema: OutputSchema = { rawResultField('rawResults'), ], }; + +export const searchModelsOutputSchema: OutputSchema = { + fields: [ + { + key: 'models', + label: 'Models', + labelKey: 'id', + listItems: [ + ...repoSearchCoreFields, + { key: 'pipeline_tag', label: 'Pipeline Task' }, + { key: 'library_name', label: 'Library' }, + { key: 'downloads', label: 'Downloads', format: 'number' }, + ], + }, + countField, + nextCursorField, + ], +}; + +export const searchDatasetsOutputSchema: OutputSchema = { + fields: [ + { + key: 'datasets', + label: 'Datasets', + labelKey: 'id', + listItems: [ + ...repoSearchCoreFields, + { key: 'author', label: 'Author' }, + { key: 'description', label: 'Description' }, + { key: 'downloads', label: 'Downloads', format: 'number' }, + { key: 'lastModified', label: 'Last Modified', format: 'datetime' }, + gatedField, + { key: 'disabled', label: 'Disabled', format: 'boolean' }, + { key: 'sha', label: 'Latest Commit SHA' }, + ], + }, + countField, + nextCursorField, + ], +}; + +export const searchSpacesOutputSchema: OutputSchema = { + fields: [ + { + key: 'spaces', + label: 'Spaces', + labelKey: 'id', + listItems: [...repoSearchCoreFields, { key: 'sdk', label: 'SDK' }], + }, + countField, + nextCursorField, + ], +}; + +export const searchPapersOutputSchema: OutputSchema = { + fields: [ + { + key: 'papers', + label: 'Papers', + labelKey: 'title', + listItems: [ + ...paperListingFields, + { + key: 'paper', + label: 'Paper', + children: [ + ...paperCoreFields, + { key: 'ai_summary', label: 'AI Summary' }, + { key: 'ai_keywords', label: 'AI Keywords' }, + ], + }, + ], + }, + countField, + ], +}; + +export const getModelOutputSchema: OutputSchema = { + fields: [ + ...repoDetailCoreFields, + { key: 'pipeline_tag', label: 'Pipeline Task' }, + { key: 'library_name', label: 'Library' }, + { key: 'downloads', label: 'Downloads (Last 30 Days)', format: 'number' }, + { + key: 'cardData', + label: 'Model Card Metadata', + children: [ + { key: 'license', label: 'License' }, + { key: 'language', label: 'Language' }, + ], + }, + { + key: 'safetensors', + label: 'Safetensors', + children: [{ key: 'total', label: 'Total Parameters', format: 'number' }], + }, + siblingsField, + { key: 'spaces', label: 'Spaces Using This Model' }, + ], +}; + +export const getDatasetOutputSchema: OutputSchema = { + fields: [ + ...repoDetailCoreFields, + { key: 'description', label: 'Description' }, + { key: 'downloads', label: 'Downloads (Last 30 Days)', format: 'number' }, + { key: 'paperswithcode_id', label: 'Papers with Code ID' }, + { + key: 'cardData', + label: 'Dataset Card Metadata', + children: [ + { key: 'pretty_name', label: 'Pretty Name' }, + { key: 'license', label: 'License' }, + { key: 'language', label: 'Language' }, + { key: 'task_categories', label: 'Task Categories' }, + { key: 'size_categories', label: 'Size Categories' }, + ], + }, + siblingsField, + ], +}; + +export const getSpaceOutputSchema: OutputSchema = { + fields: [ + ...repoDetailCoreFields, + { key: 'sdk', label: 'SDK' }, + { key: 'host', label: 'App URL', format: 'url' }, + { key: 'subdomain', label: 'Subdomain' }, + { key: 'region', label: 'Region' }, + { + key: 'cardData', + label: 'Space Card Metadata', + children: [ + { key: 'title', label: 'Title' }, + { key: 'emoji', label: 'Emoji' }, + { key: 'sdk_version', label: 'SDK Version' }, + { key: 'app_file', label: 'App File' }, + ], + }, + { + key: 'runtime', + label: 'Runtime', + children: [ + { key: 'stage', label: 'Stage' }, + { + key: 'hardware', + label: 'Hardware', + children: [ + { key: 'current', label: 'Current' }, + { key: 'requested', label: 'Requested' }, + ], + }, + ], + }, + siblingsField, + ], +}; + +export const listTrendingReposOutputSchema: OutputSchema = { + fields: [ + { + key: 'repos', + label: 'Trending Repos', + labelKey: 'id', + listItems: [ + { key: 'repo_type', label: 'Repo Type' }, + ...repoCardFields, + { key: 'title', label: 'Title', description: 'Spaces only.' }, + { key: 'emoji', label: 'Emoji', description: 'Spaces only.' }, + { key: 'shortDescription', label: 'Short Description', description: 'Spaces only.' }, + { key: 'ai_short_description', label: 'AI Short Description', description: 'Spaces only.' }, + { key: 'ai_category', label: 'AI Category', description: 'Spaces only.' }, + { + key: 'runtime', + label: 'Runtime', + description: 'Spaces only.', + children: [{ key: 'stage', label: 'Stage' }], + }, + ], + }, + countField, + ], +}; + +export const listHubTagsOutputSchema: OutputSchema = { + fields: [ + { + key: 'tags_by_type', + label: 'Tags by Type', + value: '', + dynamicKey: true, + description: + "One entry per tag type (for example 'library' or 'pipeline_tag'), each a list of {id, label, type} tags. Use a tag's id as a Tag Filters value.", + }, + ], +}; + +export const listRepoFilesOutputSchema: OutputSchema = { + fields: [ + { + key: 'files', + label: 'Files', + labelKey: 'path', + listItems: [ + { key: 'path', label: 'Path' }, + { key: 'type', label: 'Type', description: "'file' or 'directory'." }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'oid', label: 'Git Object ID' }, + { + key: 'lfs', + label: 'LFS', + description: 'Only present for files stored with Git LFS.', + children: [ + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'oid', label: 'SHA-256' }, + ], + }, + ], + }, + countField, + nextCursorField, + ], +}; + +export const getRepoPathsInfoOutputSchema: OutputSchema = { + fields: [ + { + key: 'paths', + label: 'Paths', + labelKey: 'path', + listItems: [ + { key: 'path', label: 'Path' }, + { key: 'type', label: 'Type', description: "'file' or 'directory'." }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'oid', label: 'Git Object ID' }, + { + key: 'lastCommit', + label: 'Last Commit', + children: [ + { key: 'id', label: 'Commit SHA' }, + { key: 'title', label: 'Title' }, + { key: 'date', label: 'Date', format: 'datetime' }, + ], + }, + { + key: 'securityFileStatus', + label: 'Security Status', + children: [{ key: 'status', label: 'Overall Status' }], + }, + ], + }, + countField, + ], +}; + +export const readRepoFileOutputSchema: OutputSchema = { + fields: [ + { key: 'content', label: 'Content' }, + { key: 'path', label: 'Path' }, + { key: 'size_bytes', label: 'Size', format: 'filesize' }, + { key: 'url', label: 'Download URL', format: 'url' }, + { key: 'repo_id', label: 'Repo ID' }, + { key: 'repo_type', label: 'Repo Type' }, + { key: 'revision', label: 'Revision' }, + ], +}; + +export const listRepoCommitsOutputSchema: OutputSchema = { + fields: [ + { + key: 'commits', + label: 'Commits', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'Commit SHA' }, + { key: 'title', label: 'Title' }, + { key: 'message', label: 'Message' }, + { key: 'date', label: 'Date', format: 'datetime' }, + { + key: 'authors', + label: 'Authors', + labelKey: 'user', + listItems: [ + { key: 'user', label: 'Username' }, + { key: 'avatar', label: 'Avatar', format: 'image' }, + ], + }, + ], + }, + countField, + pageField, + nextPageField, + ], +}; + +export const listRepoRefsOutputSchema: OutputSchema = { + fields: [ + { key: 'branches', label: 'Branches', labelKey: 'name', listItems: gitRefItemFields }, + { key: 'tags', label: 'Tags', labelKey: 'name', listItems: gitRefItemFields }, + { key: 'converts', label: 'Converts', labelKey: 'name', listItems: gitRefItemFields }, + { key: 'pullRequests', label: 'Pull Requests', labelKey: 'name', listItems: gitRefItemFields }, + ], +}; + +export const compareRepoRevisionsOutputSchema: OutputSchema = { + fields: [ + { key: 'diff', label: 'Diff' }, + { key: 'base', label: 'Base Revision' }, + { key: 'head', label: 'Head Revision' }, + { key: 'truncated', label: 'Truncated', format: 'boolean' }, + { key: 'diff_length', label: 'Diff Length', format: 'number' }, + ], +}; + +export const getRepoSizeOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Path' }, + { key: 'size', label: 'Size', format: 'filesize' }, + ], +}; + +export const getRepoSecurityScanOutputSchema: OutputSchema = { + fields: [ + { key: 'scansDone', label: 'Scans Done', format: 'boolean' }, + { key: 'filesWithIssues', label: 'Files With Issues' }, + ], +}; + +export const getCurrentUserOutputSchema: OutputSchema = { + fields: [ + { key: 'username', label: 'Username' }, + { key: 'full_name', label: 'Full Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'email_verified', label: 'Email Verified', format: 'boolean' }, + { key: 'is_pro', label: 'Is Pro', format: 'boolean' }, + { key: 'avatar_url', label: 'Avatar URL' }, + { key: 'account_type', label: 'Account Type' }, + { + key: 'organizations', + label: 'Organizations', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'full_name', label: 'Full Name' }, + { key: 'role_in_org', label: 'Role' }, + { key: 'is_enterprise', label: 'Is Enterprise', format: 'boolean' }, + ], + }, + { key: 'token_name', label: 'Token Name' }, + { key: 'token_created_at', label: 'Token Created At', format: 'datetime' }, + { key: 'token_role', label: 'Token Role' }, + { key: 'token_can_read_gated_repos', label: 'Token Can Read Gated Repos', format: 'boolean' }, + { key: 'token_global_permissions', label: 'Token Global Permissions' }, + { + key: 'token_scoped_permissions', + label: 'Token Scoped Permissions', + labelKey: 'entity_name', + listItems: [ + { key: 'entity_type', label: 'Entity Type' }, + { key: 'entity_name', label: 'Entity Name' }, + { key: 'permissions', label: 'Permissions' }, + ], + }, + ], +}; + +export const getUserOverviewOutputSchema: OutputSchema = { + fields: [ + { key: 'user', label: 'Username' }, + { key: 'fullname', label: 'Full Name' }, + { key: 'type', label: 'Account Type' }, + { key: 'details', label: 'Bio' }, + { key: 'avatarUrl', label: 'Avatar', format: 'image' }, + { key: 'isPro', label: 'Is Pro', format: 'boolean' }, + { key: 'createdAt', label: 'Joined At', format: 'datetime' }, + { key: 'numModels', label: 'Models', format: 'number' }, + { key: 'numDatasets', label: 'Datasets', format: 'number' }, + { key: 'numSpaces', label: 'Spaces', format: 'number' }, + { key: 'numPapers', label: 'Papers', format: 'number' }, + { key: 'numDiscussions', label: 'Discussions', format: 'number' }, + { key: 'numUpvotes', label: 'Upvotes', format: 'number' }, + { key: 'numLikes', label: 'Likes', format: 'number' }, + { key: 'numFollowers', label: 'Followers', format: 'number' }, + { key: 'numFollowing', label: 'Following', format: 'number' }, + { + key: 'orgs', + label: 'Organizations', + labelKey: 'name', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'fullname', label: 'Full Name' }, + ], + }, + ], +}; + +export const getOrganizationOverviewOutputSchema: OutputSchema = { + fields: [ + { key: 'name', label: 'Name' }, + { key: 'fullname', label: 'Full Name' }, + { key: 'details', label: 'Description' }, + { key: 'avatarUrl', label: 'Avatar', format: 'image' }, + { key: 'isVerified', label: 'Verified', format: 'boolean' }, + { key: 'plan', label: 'Plan' }, + { key: 'numUsers', label: 'Members', format: 'number' }, + { key: 'numModels', label: 'Models', format: 'number' }, + { key: 'numDatasets', label: 'Datasets', format: 'number' }, + { key: 'numSpaces', label: 'Spaces', format: 'number' }, + { key: 'numPapers', label: 'Papers', format: 'number' }, + { key: 'numFollowers', label: 'Followers', format: 'number' }, + ], +}; + +export const listOrganizationMembersOutputSchema: OutputSchema = { + fields: [ + { + key: 'members', + label: 'Members', + labelKey: 'user', + listItems: [ + { key: 'user', label: 'Username' }, + { key: 'fullname', label: 'Full Name' }, + { key: 'type', label: 'Account Type' }, + { key: 'isPro', label: 'Is Pro', format: 'boolean' }, + ], + }, + countField, + nextCursorField, + ], +}; + +export const listDiscussionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'discussions', + label: 'Discussions', + labelKey: 'title', + listItems: [...discussionSummaryFields, { key: 'numComments', label: 'Comments', format: 'number' }], + }, + countField, + { key: 'total_count', label: 'Total Count', format: 'number' }, + pageField, + nextPageField, + ], +}; + +export const getDiscussionOutputSchema: OutputSchema = { + fields: [ + ...discussionSummaryFields, + { key: 'locked', label: 'Locked', format: 'boolean' }, + { key: 'events', label: 'Events', labelKey: 'type', listItems: commentEventFields }, + ], +}; + +export const listDailyPapersOutputSchema: OutputSchema = { + fields: [ + { + key: 'papers', + label: 'Papers', + labelKey: 'title', + listItems: [ + ...paperListingFields, + { + key: 'paper', + label: 'Paper', + children: [ + ...paperCoreFields, + { key: 'submittedOnDailyAt', label: 'Submitted to Daily Papers At', format: 'datetime' }, + ], + }, + { key: 'submittedBy', label: 'Submitted By', children: hubAccountFields }, + ], + }, + countField, + pageField, + nextPageField, + ], +}; + +export const getPaperOutputSchema: OutputSchema = { + fields: [ + ...paperCoreFields, + { key: 'ai_summary', label: 'AI Summary' }, + { key: 'ai_keywords', label: 'AI Keywords' }, + { key: 'submittedOnDailyAt', label: 'Submitted to Daily Papers At', format: 'datetime' }, + { + key: 'submittedOnDailyBy', + label: 'Submitted By', + children: [ + { key: 'user', label: 'Username' }, + { key: 'fullname', label: 'Full Name' }, + ], + }, + { key: 'githubRepo', label: 'GitHub Repo', format: 'url' }, + { key: 'githubStars', label: 'GitHub Stars', format: 'number' }, + { key: 'linkedModels', label: 'Linked Models', labelKey: 'id', listItems: repoCardFields }, + { key: 'numTotalModels', label: 'Total Linked Models', format: 'number' }, + { key: 'linkedDatasets', label: 'Linked Datasets', labelKey: 'id', listItems: repoCardBaseFields }, + { key: 'numTotalDatasets', label: 'Total Linked Datasets', format: 'number' }, + { + key: 'linkedSpaces', + label: 'Linked Spaces', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Space ID' }, + { key: 'emoji', label: 'Emoji' }, + { key: 'shortDescription', label: 'Short Description' }, + { key: 'running', label: 'Running', format: 'boolean' }, + ], + }, + { key: 'numTotalSpaces', label: 'Total Linked Spaces', format: 'number' }, + { + key: 'comments', + label: 'Comments', + labelKey: 'id', + description: 'Only present when Include Comments is on.', + listItems: [ + ...commentEventFields, + { key: 'replies', label: 'Replies', labelKey: 'id', listItems: commentEventFields }, + ], + }, + ], +}; + +export const listCollectionsOutputSchema: OutputSchema = { + fields: [ + { key: 'collections', label: 'Collections', labelKey: 'title', listItems: collectionFields }, + countField, + nextCursorField, + ], +}; + +export const getCollectionOutputSchema: OutputSchema = { + fields: [...collectionFields, { key: 'shareUrl', label: 'Share URL', format: 'url' }], +}; + +export const generateChatCompletionOutputSchema: OutputSchema = { + fields: [ + { + key: 'content', + label: 'Reply', + description: "Empty with finish_reason 'length' means Max Tokens ran out, often on hidden reasoning.", + }, + { key: 'finish_reason', label: 'Finish Reason' }, + { key: 'model', label: 'Model' }, + { key: 'id', label: 'Completion ID' }, + { + key: 'usage', + label: 'Token Usage', + children: [ + { key: 'prompt_tokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'completion_tokens', label: 'Completion Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, + ], + }, + ], +}; + +export const generateEmbeddingsOutputSchema: OutputSchema = { + fields: [ + { key: 'model', label: 'Model' }, + { key: 'dimensions', label: 'Dimensions', format: 'number' }, + { + key: 'embedding', + label: 'Embedding', + description: 'The vector for a single input text. Only present when one text was sent.', + }, + { + key: 'embeddings', + label: 'Embeddings', + description: 'One vector per input text, in input order. Only present when several texts were sent.', + }, + { + key: 'count', + label: 'Count', + format: 'number', + description: 'Number of vectors returned. Only present when several texts were sent.', + }, + ], +}; diff --git a/packages/pieces/community/hugging-face/test/helpers.ts b/packages/pieces/community/hugging-face/test/helpers.ts new file mode 100644 index 000000000000..a2132688316c --- /dev/null +++ b/packages/pieces/community/hugging-face/test/helpers.ts @@ -0,0 +1,31 @@ +import { Readable } from 'node:stream'; +import { + ActionContext, + AppConnectionType, + InputPropertyMap, + SecretTextProperty, + StaticPropsValue, +} from '@activepieces/pieces-framework'; +import { createMockActionContext } from '../../../framework/src/lib/test'; + +export function runAction({ action, propsValue }: RunActionParams) { + const context: ActionContext, Props> = { + ...createMockActionContext({ propsValue }), + auth: { type: AppConnectionType.SECRET_TEXT, secret_text: TEST_TOKEN }, + }; + return action.run(context); +} + +export function streamOf(content: string | Buffer): Readable { + return Readable.from([typeof content === 'string' ? Buffer.from(content) : content]); +} + +export const TEST_TOKEN = 'hf_test_connection_token'; + +type RunActionParams = { + action: { + props: Props; + run: (context: ActionContext, Props>) => Promise; + }; + propsValue: StaticPropsValue; +}; diff --git a/packages/pieces/community/hugging-face/test/hub.test.ts b/packages/pieces/community/hugging-face/test/hub.test.ts new file mode 100644 index 000000000000..0b0f757e0272 --- /dev/null +++ b/packages/pieces/community/hugging-face/test/hub.test.ts @@ -0,0 +1,280 @@ +import { AuthenticationType, HttpMethod } from '@activepieces/pieces-common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentUser } from '../src/lib/actions/get-current-user'; +import { readRepoFile } from '../src/lib/actions/read-repo-file'; +import { searchModels } from '../src/lib/actions/search-models'; +import { hfHub } from '../src/lib/common/hub-client'; +import { hfRepo } from '../src/lib/common/repo'; +import { StaticPropsValue } from '@activepieces/pieces-framework'; +import { runAction, streamOf, TEST_TOKEN } from './helpers'; + +const sendRequest = vi.fn(); + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + httpClient: { + sendRequest: (...args: unknown[]) => sendRequest(...args), + }, + }; +}); + +function readFile(overrides: Partial>) { + return runAction({ + action: readRepoFile, + propsValue: { + repo_type: 'model', + repo_id: 'openai-community/gpt2', + path: 'README.md', + revision: undefined, + ...overrides, + }, + }); +} + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('Link header pagination', () => { + it('extracts the next cursor from a rel="next" Link header', () => { + const headers = { + link: '; rel="next"', + }; + + expect(hfHub.parseNextCursor(headers)).toBe('eyJwYWdlIjoyfQ=='); + }); + + it('returns null when there is no next link', () => { + expect(hfHub.parseNextCursor({})).toBeNull(); + expect(hfHub.parseNextCursor(undefined)).toBeNull(); + expect(hfHub.parseNextCursor({ link: '; rel="prev"' })).toBeNull(); + }); + + it('reads other query params from the next link', () => { + const headers = { link: '; rel="next"' }; + + expect(hfHub.parseNextLinkParam({ headers, param: 'p' })).toBe('3'); + }); + + it('surfaces next_cursor on a search action and forwards the cursor', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { link: '; rel="next"' }, + body: [{ id: 'openai/gpt-oss-20b' }], + }); + + const result = await runAction({ + action: searchModels, + propsValue: { + search: 'gpt', + author: undefined, + filter: undefined, + pipeline_tag: undefined, + inference_provider: undefined, + sort: undefined, + limit: 1, + full: undefined, + cursor: 'prev456', + }, + }); + + expect(result).toMatchObject({ next_cursor: 'next123' }); + const url = new URL(sendRequest.mock.calls[0][0].url); + expect(url.searchParams.get('cursor')).toBe('prev456'); + }); +}); + +describe('repository id and revision encoding', () => { + it('encodes each id segment separately and keeps the slash', () => { + expect(hfRepo.encodeRepoId('my org/my model')).toBe('my%20org/my%20model'); + expect(hfRepo.encodeRepoId('/openai-community/gpt2/')).toBe('openai-community/gpt2'); + }); + + it('rejects ids with more than two segments', () => { + expect(() => hfRepo.encodeRepoId('a/b/c')).toThrow(/Invalid repository ID/); + }); + + it('encodes a PR ref as one path segment and defaults to main', () => { + expect(hfRepo.encodeRevision('refs/pr/1')).toBe('refs%2Fpr%2F1'); + expect(hfRepo.encodeRevision(undefined)).toBe('main'); + expect(hfRepo.encodeRevision(' ')).toBe('main'); + }); + + it('encodes file path segments but keeps folder separators', () => { + expect(hfRepo.encodeFilePath('configs/my file#1.json')).toBe('configs/my%20file%231.json'); + }); + + it('resolves a legacy single-segment id through the Hub API', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'openai-community/gpt2' } }); + + const canonical = await hfRepo.resolveRepoId({ token: TEST_TOKEN, repoType: 'model', repoId: 'gpt2' }); + + expect(canonical).toBe('openai-community/gpt2'); + expect(sendRequest.mock.calls[0][0].url).toBe('https://huggingface.co/api/models/gpt2'); + }); +}); + +describe('read_repo_file', () => { + it('builds the resolve URL with encoded id, revision and path', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: streamOf('# Hi') }); + + const result = await readFile({ + repo_type: 'dataset', + repo_id: 'my org/data', + revision: 'refs/pr/1', + path: 'docs/read me.md', + }); + + const expectedUrl = 'https://huggingface.co/datasets/my%20org/data/resolve/refs%2Fpr%2F1/docs/read%20me.md'; + expect(sendRequest.mock.calls[0][0]).toMatchObject({ + method: HttpMethod.GET, + url: expectedUrl, + followRedirects: false, + responseType: 'stream', + authentication: { type: AuthenticationType.BEARER_TOKEN, token: TEST_TOKEN }, + }); + expect(result).toEqual({ + repo_type: 'dataset', + repo_id: 'my org/data', + revision: 'refs/pr/1', + path: 'docs/read me.md', + content: '# Hi', + size_bytes: 4, + url: expectedUrl, + }); + }); + + it('keeps Authorization on a same-host redirect and drops it on a cross-host redirect', async () => { + sendRequest + .mockResolvedValueOnce({ + status: 307, + headers: { location: '/api/resolve-cache/models/openai-community/gpt2/abc/README.md' }, + body: streamOf(''), + }) + .mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cas-bridge.xethub.hf.co/xet-bridge/abc?X-Amz-Signature=sig' }, + body: streamOf(''), + }) + .mockResolvedValueOnce({ status: 200, headers: {}, body: streamOf('hello') }); + + const result = await readFile({}); + + expect(sendRequest).toHaveBeenCalledTimes(3); + const [first, second, third] = sendRequest.mock.calls.map((call) => call[0]); + expect(first.authentication).toEqual({ type: AuthenticationType.BEARER_TOKEN, token: TEST_TOKEN }); + expect(second.url).toBe('https://huggingface.co/api/resolve-cache/models/openai-community/gpt2/abc/README.md'); + expect(second.authentication).toEqual({ type: AuthenticationType.BEARER_TOKEN, token: TEST_TOKEN }); + expect(third.url).toBe('https://cas-bridge.xethub.hf.co/xet-bridge/abc?X-Amz-Signature=sig'); + expect(third.authentication).toBeUndefined(); + expect(JSON.stringify(third)).not.toContain(TEST_TOKEN); + expect(result).toMatchObject({ content: 'hello', size_bytes: 5 }); + }); + + it('fails with FILE_TOO_LARGE from the redirect x-linked-size before following it', async () => { + sendRequest.mockResolvedValueOnce({ + status: 302, + headers: { location: 'https://cdn-lfs.hf.co/file', 'x-linked-size': String(5 * 1024 * 1024) }, + body: streamOf(''), + }); + + await expect(readFile({ path: 'model.safetensors' })).rejects.toThrow(/^FILE_TOO_LARGE: the file is 5242880 bytes/); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('fails with FILE_TOO_LARGE from content-length', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'content-length': String(hfHub.maxTextFileBytes + 1) }, + body: streamOf('x'), + }); + + await expect(readFile({})).rejects.toThrow(/^FILE_TOO_LARGE/); + }); + + it('fails with FILE_TOO_LARGE when an unsized stream exceeds the cap', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: streamOf(Buffer.alloc(hfHub.maxTextFileBytes + 1, 97)), + }); + + await expect(readFile({})).rejects.toThrow(/^FILE_TOO_LARGE: the file is more than/); + }); + + it('fails with BINARY_FILE for content with NUL bytes', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: streamOf(Buffer.from([0x50, 0x4b, 0x00, 0x03])) }); + + await expect(readFile({ path: 'weights.bin' })).rejects.toThrow(/^BINARY_FILE/); + }); + + it('fails with BINARY_FILE for invalid UTF-8', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: streamOf(Buffer.from([0xff, 0xfe, 0x41])) }); + + await expect(readFile({})).rejects.toThrow(/^BINARY_FILE/); + }); + + it('stops after too many redirects', async () => { + sendRequest.mockImplementation(async () => ({ + status: 302, + headers: { location: 'https://huggingface.co/loop' }, + body: streamOf(''), + })); + + await expect(readFile({})).rejects.toThrow(/Too many redirects/); + }); + + it('rejects an empty file path without a request', async () => { + await expect(readFile({ path: '//' })).rejects.toThrow(/File Path is required/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('get_current_user', () => { + it('returns token metadata but never token material', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: { + name: 'odai', + fullname: 'Odai', + type: 'user', + orgs: [{ name: 'acme', fullname: 'Acme', roleInOrg: 'admin', isEnterprise: false }], + auth: { + type: 'access_token', + accessToken: { + displayName: 'automation', + role: 'fineGrained', + createdAt: '2026-01-02T03:04:05.000Z', + token: 'hf_leaked_value_from_api', + fineGrained: { + canReadGatedRepos: true, + global: ['inference.serverless.write'], + scoped: [{ entity: { type: 'user', name: 'odai' }, permissions: ['repo.content.read'] }], + }, + }, + }, + }, + }); + + const result = await runAction({ action: getCurrentUser, propsValue: {} }); + + expect(sendRequest.mock.calls[0][0].url).toBe('https://huggingface.co/api/whoami-v2'); + expect(result).toMatchObject({ + username: 'odai', + token_name: 'automation', + token_role: 'fineGrained', + token_created_at: '2026-01-02T03:04:05.000Z', + token_can_read_gated_repos: true, + token_global_permissions: ['inference.serverless.write'], + organizations: [{ name: 'acme', role_in_org: 'admin' }], + }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain('hf_leaked_value_from_api'); + expect(serialized).not.toContain(TEST_TOKEN); + expect(result).not.toHaveProperty('token'); + expect(result).not.toHaveProperty('access_token'); + }); +}); diff --git a/packages/pieces/community/hugging-face/test/inference.test.ts b/packages/pieces/community/hugging-face/test/inference.test.ts new file mode 100644 index 000000000000..c135d7e5e024 --- /dev/null +++ b/packages/pieces/community/hugging-face/test/inference.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateEmbeddings } from '../src/lib/actions/generate-embeddings'; +import { generateChatCompletion } from '../src/lib/actions/generate-chat-completion'; +import { hfInference } from '../src/lib/common/inference'; +import { StaticPropsValue } from '@activepieces/pieces-framework'; +import { runAction, TEST_TOKEN } from './helpers'; + +const featureExtraction = vi.fn(); +const chatCompletion = vi.fn(); +const clientTokens: string[] = []; + +vi.mock('@huggingface/inference', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + InferenceClient: class { + constructor(token: string) { + clientTokens.push(token); + } + featureExtraction = (...args: unknown[]) => featureExtraction(...args); + chatCompletion = (...args: unknown[]) => chatCompletion(...args); + }, + }; +}); + +function runEmbeddings(overrides: Partial>) { + return runAction({ + action: generateEmbeddings, + propsValue: { + model: 'BAAI/bge-small-en-v1.5', + provider: undefined, + inputs: [], + normalize: undefined, + truncate: undefined, + truncation_direction: undefined, + prompt_name: undefined, + ...overrides, + }, + }); +} + +function runChat(overrides: Partial>) { + return runAction({ + action: generateChatCompletion, + propsValue: { + model: undefined, + provider: undefined, + messages: JSON.parse('[{"role": "user", "content": "Hi"}]'), + system_prompt: undefined, + max_tokens: undefined, + temperature: undefined, + top_p: undefined, + stop: undefined, + seed: undefined, + response_format: undefined, + json_schema: undefined, + ...overrides, + }, + }); +} + +beforeEach(() => { + featureExtraction.mockReset(); + chatCompletion.mockReset(); + clientTokens.length = 0; +}); + +describe('generate_embeddings shape handling', () => { + it('returns a flat vector for one text as the embedding', async () => { + featureExtraction.mockResolvedValueOnce([0.1, 0.2, 0.3]); + + const result = await runEmbeddings({ inputs: ['hello'] }); + + expect(result).toEqual({ model: 'BAAI/bge-small-en-v1.5', embedding: [0.1, 0.2, 0.3], dimensions: 3 }); + expect(featureExtraction.mock.calls[0][0].inputs).toBe('hello'); + expect(clientTokens).toEqual([TEST_TOKEN]); + }); + + it('unwraps a single pooled vector wrapped in an outer array', async () => { + featureExtraction.mockResolvedValueOnce([[0.5, 0.6]]); + + const result = await runEmbeddings({ inputs: ['hello'] }); + + expect(result).toEqual({ model: 'BAAI/bge-small-en-v1.5', embedding: [0.5, 0.6], dimensions: 2 }); + }); + + it('rejects token-level vectors for one text instead of returning the first token', async () => { + featureExtraction.mockResolvedValueOnce([ + [0.1, 0.2], + [0.3, 0.4], + [0.5, 0.6], + ]); + + await expect(runEmbeddings({ inputs: ['hello world'] })).rejects.toThrow(/token-level vectors/); + }); + + it('rejects token-level vectors for one text wrapped in a batch array', async () => { + featureExtraction.mockResolvedValueOnce([ + [ + [0.1, 0.2], + [0.3, 0.4], + ], + ]); + + await expect(runEmbeddings({ inputs: ['hello world'] })).rejects.toThrow(/token-level vectors/); + }); + + it('returns one pooled vector per text, in order, for several texts', async () => { + featureExtraction.mockResolvedValueOnce([ + [1, 2], + [3, 4], + ]); + + const result = await runEmbeddings({ inputs: ['a', 'b'] }); + + expect(result).toEqual({ + model: 'BAAI/bge-small-en-v1.5', + embeddings: [ + [1, 2], + [3, 4], + ], + count: 2, + dimensions: 2, + }); + expect(featureExtraction.mock.calls[0][0].inputs).toEqual(['a', 'b']); + }); + + it('rejects token-level vectors for several texts with the same typed error', async () => { + featureExtraction.mockResolvedValueOnce([ + [ + [1, 2], + [3, 4], + ], + [ + [5, 6], + [7, 8], + ], + ]); + + await expect(runEmbeddings({ inputs: ['a', 'b'] })).rejects.toThrow(/token-level vectors/); + }); + + it('rejects a result whose count does not match the number of texts', async () => { + featureExtraction.mockResolvedValueOnce([[1, 2]]); + + await expect(runEmbeddings({ inputs: ['a', 'b'] })).rejects.toThrow(/one pooled embedding per text/); + }); + + it('rejects empty input before calling the provider', async () => { + await expect(runEmbeddings({ inputs: [' '] })).rejects.toThrow(/at least one non-empty text/); + expect(featureExtraction).not.toHaveBeenCalled(); + }); +}); + +describe('resolveModelAndProvider', () => { + it('falls back to the default model when the model is empty', () => { + expect(hfInference.resolveModelAndProvider({ model: ' ', provider: undefined, defaultModel: 'openai/gpt-oss-20b' })).toEqual({ + model: 'openai/gpt-oss-20b', + provider: undefined, + }); + }); + + it('keeps a model without a suffix and uses the selected provider', () => { + expect(hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B', provider: 'groq' })).toEqual({ + model: 'Qwen/Qwen3-8B', + provider: 'groq', + }); + }); + + it('turns a :groq suffix into the provider', () => { + expect(hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:groq', provider: undefined })).toEqual({ + model: 'Qwen/Qwen3-8B', + provider: 'groq', + }); + }); + + it('maps :preferred to the auto policy', () => { + expect(hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:preferred', provider: undefined })).toEqual({ + model: 'Qwen/Qwen3-8B', + provider: 'auto', + }); + }); + + it('rejects the :fastest and :cheapest routing policies', () => { + expect(() => hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:fastest', provider: undefined })).toThrow( + /':fastest' routing policy is not supported/ + ); + expect(() => hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:cheapest', provider: undefined })).toThrow( + /':cheapest' routing policy is not supported/ + ); + }); + + it('rejects a suffix that conflicts with the selected provider', () => { + expect(() => hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:groq', provider: 'together' })).toThrow( + /conflicts with the selected provider 'together'/ + ); + }); + + it('accepts a suffix that matches the selected provider', () => { + expect(hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:groq', provider: 'groq' })).toEqual({ + model: 'Qwen/Qwen3-8B', + provider: 'groq', + }); + }); + + it('rejects unknown providers and suffixes', () => { + expect(() => hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B', provider: 'nope' })).toThrow( + /Unknown inference provider 'nope'/ + ); + expect(() => hfInference.resolveModelAndProvider({ model: 'Qwen/Qwen3-8B:nope', provider: undefined })).toThrow( + /Unknown provider suffix ':nope'/ + ); + }); +}); + +describe('generate_chat_completion model selection', () => { + it('uses openai/gpt-oss-20b when the model is left empty', async () => { + chatCompletion.mockResolvedValueOnce({ choices: [{ message: { content: 'Hello' }, finish_reason: 'stop' }] }); + + const result = await runChat({ model: undefined }); + + expect(chatCompletion.mock.calls[0][0]).toMatchObject({ model: 'openai/gpt-oss-20b', provider: undefined }); + expect(result).toMatchObject({ content: 'Hello', finish_reason: 'stop', model: 'openai/gpt-oss-20b' }); + }); + + it('accepts any typed model ID with a provider suffix', async () => { + chatCompletion.mockResolvedValueOnce({ model: 'meta-llama/Llama-3.1-8B-Instruct', choices: [] }); + + await runChat({ model: 'meta-llama/Llama-3.1-8B-Instruct:groq' }); + + expect(chatCompletion.mock.calls[0][0]).toMatchObject({ + model: 'meta-llama/Llama-3.1-8B-Instruct', + provider: 'groq', + }); + }); + + it('prepends the system prompt as a leading system message', async () => { + chatCompletion.mockResolvedValueOnce({ choices: [] }); + + await runChat({ model: 'openai/gpt-oss-20b', system_prompt: 'Be brief.' }); + + expect(chatCompletion.mock.calls[0][0].messages).toEqual([ + { role: 'system', content: 'Be brief.' }, + { role: 'user', content: 'Hi' }, + ]); + }); + + it('rejects a routing policy suffix before calling the provider', async () => { + await expect(runChat({ model: 'openai/gpt-oss-20b:fastest' })).rejects.toThrow(/not supported/); + expect(chatCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pieces/community/hugging-face/vitest.config.ts b/packages/pieces/community/hugging-face/vitest.config.ts new file mode 100644 index 000000000000..79c972619047 --- /dev/null +++ b/packages/pieces/community/hugging-face/vitest.config.ts @@ -0,0 +1,19 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + root: __dirname, + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +}) From 9cacaf403ac48c718dbf2e1f0c8786f7c28bd4ac Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Fri, 25 Sep 2026 15:25:59 +0300 Subject: [PATCH 4/8] feat(OpenRouter): step form (#15484) --- .../pieces/community/open-router/package.json | 2 ++ .../pieces/community/open-router/src/index.ts | 4 +++- .../src/lib/actions/ask-open-router.ts | 24 ++++++++++--------- .../community/open-router/src/lib/auth.ts | 13 ++++------ .../open-router/src/lib/common/index.ts | 1 + 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/pieces/community/open-router/package.json b/packages/pieces/community/open-router/package.json index 3b54daa3d7c7..b0e80942662e 100644 --- a/packages/pieces/community/open-router/package.json +++ b/packages/pieces/community/open-router/package.json @@ -1,6 +1,8 @@ { "name": "@activepieces/piece-open-router", + "version": "0.3.0", + "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/open-router/src/index.ts b/packages/pieces/community/open-router/src/index.ts index cd0519938dac..9f552a4e95ba 100644 --- a/packages/pieces/community/open-router/src/index.ts +++ b/packages/pieces/community/open-router/src/index.ts @@ -15,7 +15,9 @@ export const openRouter = createPiece({ displayName: 'OpenRouter', description: 'Use any AI model to generate code, text, or images via OpenRouter.ai.', auth: openRouterAuth, - minimumSupportedRelease: '0.87.0', + + minimumSupportedRelease: '0.88.2', + logoUrl: 'https://cdn.activepieces.com/pieces/open-router.png', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], authors: ["Salem-Alaa","kishanprmr","MoShizzle","abuaboud"], diff --git a/packages/pieces/community/open-router/src/lib/actions/ask-open-router.ts b/packages/pieces/community/open-router/src/lib/actions/ask-open-router.ts index 7ad5ebd46c90..1dc846b24e50 100644 --- a/packages/pieces/community/open-router/src/lib/actions/ask-open-router.ts +++ b/packages/pieces/community/open-router/src/lib/actions/ask-open-router.ts @@ -19,15 +19,14 @@ export const askOpenRouterAction = createAction({ name: 'ask-lmm', classification: 'READ', displayName: 'Ask LLM', - description: 'Ask any model supported by Open Router.', + description: 'Send a prompt to any model on OpenRouter and get the reply.', aiMetadata: { description: 'Sends a single prompt to any model in the OpenRouter catalog through one unified completions endpoint and returns the generated text, with optional temperature, top-p, and max-token controls. It is the only first-class action in the piece and is single-turn - no conversation memory, no system-role array, and no image or audio input - so use the sibling Custom API Call for any other OpenRouter endpoint, and prefer a vendor-specific piece such as OpenAI, Anthropic, or Groq when the model must come from one provider. Requires a model id from the OpenRouter model list and a prompt; not idempotent: each call bills a fresh generation and may return different text for the same input.', idempotent: false }, auth: openRouterAuth, props: { model: Property.Dropdown({ auth: openRouterAuth, displayName: 'Model', - description: - 'The model which will generate the completion. Some models are suitable for natural language tasks, others specialize in code.', + description: 'The model that writes the reply. Type to search by name.', required: true, refreshers: [], defaultValue: 'pygmalionai/mythalion-13b', @@ -54,7 +53,7 @@ export const askOpenRouterAction = createAction({ const options = response.body.data.map((model) => { return { - label: model.id, + label: model.name || model.id, value: model.id, }; }); @@ -62,11 +61,11 @@ export const askOpenRouterAction = createAction({ options: options, disabled: false, }; - } catch (error) { + } catch { return { options: [], disabled: true, - placeholder: `Couldn't Load Models:\n${error}`, + placeholder: 'Could not load models. Check your API key and try again.', }; } }, @@ -74,31 +73,34 @@ export const askOpenRouterAction = createAction({ prompt: Property.LongText({ displayName: 'Prompt', required: true, - description: 'The prompt to send to the model.', + placeholder: 'e.g. Summarize the text below in three bullet points', }), temperature: Property.Number({ displayName: 'Temperature', required: false, + advanced: true, description: - 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', + 'Randomness from 0 to 2. Lower is focused, higher is creative.', }), maxTokens: Property.Number({ displayName: 'Maximum Tokens', required: false, + advanced: true, description: - "The maximum number of tokens to generate. Requests can use up to 2,048 or 4,096 tokens shared between prompt and completion, don't set the value to maximum and leave some tokens for the input. The exact limit varies by model. (One token is roughly 4 characters for normal English text)", + 'Longest reply allowed, in tokens. Empty lets the model decide.', }), topP: Property.Number({ displayName: 'Top P', required: false, + advanced: true, description: - 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.', + 'Nucleus sampling from 0 to 1. Lower keeps only the likeliest words.', }), }, outputSchema: askLmmActionOutputSchema, async run(context) { await propsValidation.validateZod(context.propsValue, { - temperature: z.optional(z.number().check(z.minimum(0), z.maximum(1.0))), + temperature: z.optional(z.number().check(z.minimum(0), z.maximum(2))), topP: z.optional(z.number().check(z.minimum(0), z.maximum(1.0))), }); diff --git a/packages/pieces/community/open-router/src/lib/auth.ts b/packages/pieces/community/open-router/src/lib/auth.ts index ce29b723029a..6f0d30ab7a1d 100644 --- a/packages/pieces/community/open-router/src/lib/auth.ts +++ b/packages/pieces/community/open-router/src/lib/auth.ts @@ -2,21 +2,16 @@ import { PieceAuth } from '@activepieces/pieces-framework'; import { AuthenticationType, httpClient, HttpMethod, HttpRequest } from '@activepieces/pieces-common'; const markdownDescription = ` -Follow these instructions to get your OpenAI API Key: - -1. Visit the following website: https://openrouter.ai/keys. -2. Once on the website, click on create a key. -3. Once you have created a key, copy it and use it for the Api key field on the site. +1. Open [openrouter.ai/keys](https://openrouter.ai/keys) and sign in. +2. Click **Create Key**, name it and copy the key. +3. Paste it here. It starts with \`sk-or-\`. `; export const openRouterAuth = PieceAuth.SecretText({ description: markdownDescription, - displayName: 'Api Key', + displayName: 'API Key', required: true, validate: async ({ auth }) => { - // we send a get request to https://openrouter.ai/api/v1/auth/key with the key as a header - // if the response is 200, then the key is valid - // if the response is 401, then the key is invalid try { const request: HttpRequest = { url: 'https://openrouter.ai/api/v1/auth/key', diff --git a/packages/pieces/community/open-router/src/lib/common/index.ts b/packages/pieces/community/open-router/src/lib/common/index.ts index 5edea151b82b..3953c7970734 100644 --- a/packages/pieces/community/open-router/src/lib/common/index.ts +++ b/packages/pieces/community/open-router/src/lib/common/index.ts @@ -33,6 +33,7 @@ export interface promptResponse { export interface openRouterModels { data: { id: string; + name: string; }[]; } From ddb68e18ee0133c9ab2b8898438945f244b74446 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Fri, 25 Sep 2026 18:39:12 +0300 Subject: [PATCH 5/8] feat(tally): improve the connection and New Submission step forms (#15396) --- bun.lock | 2 +- packages/pieces/community/tally/package.json | 2 +- .../community/tally/src/i18n/translation.json | 113 +++++++++++++++++- packages/pieces/community/tally/src/index.ts | 2 +- .../pieces/community/tally/src/lib/auth.ts | 7 +- .../community/tally/src/lib/common/props.ts | 36 +++++- .../tally/src/lib/triggers/new-submission.ts | 18 ++- 7 files changed, 159 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index 18c874d0cb80..ba264c0f95ba 100644 --- a/bun.lock +++ b/bun.lock @@ -8854,7 +8854,7 @@ }, "packages/pieces/community/tally": { "name": "@activepieces/piece-tally", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/tally/package.json b/packages/pieces/community/tally/package.json index dff50780c884..3b44069cae97 100644 --- a/packages/pieces/community/tally/package.json +++ b/packages/pieces/community/tally/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-tally", - "version": "0.6.0", + "version": "0.6.1", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/tally/src/i18n/translation.json b/packages/pieces/community/tally/src/i18n/translation.json index ea1f26efcf20..fa2f6d16bf04 100644 --- a/packages/pieces/community/tally/src/i18n/translation.json +++ b/packages/pieces/community/tally/src/i18n/translation.json @@ -1,19 +1,121 @@ { "Receive form submissions from Tally forms": "Receive form submissions from Tally forms", - "You can obtain API key from [Settings page](https://tally.so/settings/api-keys)": "You can obtain API key from [Settings page](https://tally.so/settings/api-keys)", + "To get your API key:\n\n1. Sign in to [Tally](https://tally.so) and open **Settings**.\n2. Go to **API keys** and click **Create API key**.\n3. Copy the key and paste it below.": "To get your API key:\n\n1. Sign in to [Tally](https://tally.so) and open **Settings**.\n2. Go to **API keys** and click **Create API key**.\n3. Copy the key and paste it below.", + "List Forms": "List Forms", + "Create Form": "Create Form", + "Get Form": "Get Form", + "Update Form": "Update Form", + "Delete Form": "Delete Form", + "List Form Questions": "List Form Questions", + "List Submissions": "List Submissions", + "Get Submission": "Get Submission", + "Delete Submission": "Delete Submission", + "Get Form Metrics": "Get Form Metrics", + "Get Form Visit Analytics": "Get Form Visit Analytics", + "Get Form Submission Analytics": "Get Form Submission Analytics", + "Get Form Analytics Dimensions": "Get Form Analytics Dimensions", + "Get Form Drop-off Analytics": "Get Form Drop-off Analytics", + "List Workspaces": "List Workspaces", + "Create Workspace": "Create Workspace", + "Get Workspace": "Get Workspace", + "Rename Workspace": "Rename Workspace", + "Delete Workspace": "Delete Workspace", + "List Workspace Folders": "List Workspace Folders", + "Create Folder": "Create Folder", + "Rename Folder": "Rename Folder", + "Delete Folder": "Delete Folder", + "Get Current User": "Get Current User", "Custom API Call": "Custom API Call", + "List all forms in your Tally account": "List all forms in your Tally account", + "Create a new form from a blocks definition": "Create a new form from a blocks definition", + "Get a single form by id, including its blocks and settings": "Get a single form by id, including its blocks and settings", + "Update a form's name, status, blocks, or settings": "Update a form's name, status, blocks, or settings", + "Permanently delete a form": "Permanently delete a form", + "List the questions on a form, with their ids and titles": "List the questions on a form, with their ids and titles", + "List a form's submissions, with filters for status and date range": "List a form's submissions, with filters for status and date range", + "Get a single submission by id": "Get a single submission by id", + "Permanently delete a single submission": "Permanently delete a single submission", + "Get summary metrics for a form over a time period": "Get summary metrics for a form over a time period", + "Get visit counts over time for a form": "Get visit counts over time for a form", + "Get completed vs partial submission counts over time for a form": "Get completed vs partial submission counts over time for a form", + "Get visitor breakdowns by source, browser, OS, device, and location for a form": "Get visitor breakdowns by source, browser, OS, device, and location for a form", + "Get per-question drop-off rates for a form": "Get per-question drop-off rates for a form", + "List all workspaces in your Tally account": "List all workspaces in your Tally account", + "Create a new workspace": "Create a new workspace", + "Get a single workspace by id": "Get a single workspace by id", + "Rename a workspace": "Rename a workspace", + "Permanently delete a workspace and everything inside it": "Permanently delete a workspace and everything inside it", + "List folders inside a workspace": "List folders inside a workspace", + "Create a folder inside a workspace": "Create a folder inside a workspace", + "Rename a folder": "Rename a folder", + "Delete a folder and its subtree, moving contained forms to trash": "Delete a folder and its subtree, moving contained forms to trash", + "Get the connected account's profile and subscription plan": "Get the connected account's profile and subscription plan", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", + "Page": "Page", + "Limit": "Limit", + "Blocks": "Blocks", + "Status": "Status", + "Workspace": "Workspace", + "Folder": "Folder", + "Template ID": "Template ID", + "Settings": "Settings", + "Form": "Form", + "Name": "Name", + "Filter": "Filter", + "Start Date": "Start Date", + "End Date": "End Date", + "After Submission ID": "After Submission ID", + "Submission ID": "Submission ID", + "Period": "Period", + "New Name": "New Name", "Method": "Method", "Headers": "Headers", "Query Parameters": "Query Parameters", "Body Type": "Body Type", "Body": "Body", - "Response is Binary ?": "Response is Binary ?", - "No Error on Failure": "No Error on Failure", - "Timeout (in seconds)": "Timeout (in seconds)", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", "Follow redirects": "Follow redirects", + "Page number, starting at 1. Defaults to 1.": "Page number, starting at 1. Defaults to 1.", + "Number of forms per page (max 500). Defaults to 50.": "Number of forms per page (max 500). Defaults to 50.", + "Array of Tally block objects that define the form content and fields (titles, inputs, choices, etc). Fetch an existing form with Get Form to see the exact shape blocks must take.": "Array of Tally block objects that define the form content and fields (titles, inputs, choices, etc). Fetch an existing form with Get Form to see the exact shape blocks must take.", + "BLANK has no content yet; DRAFT is editable and not publicly accessible; PUBLISHED is live and accepting submissions.": "BLANK has no content yet; DRAFT is editable and not publicly accessible; PUBLISHED is live and accepting submissions.", + "Defaults to your account's default workspace if left empty.": "Defaults to your account's default workspace if left empty.", + "Only folders inside the selected workspace are shown.": "Only folders inside the selected workspace are shown.", + "Optional Tally template ID to base the form on.": "Optional Tally template ID to base the form on.", + "Optional form settings object (language, notifications, redirects, data retention, styling, password protection, etc).": "Optional form settings object (language, notifications, redirects, data retention, styling, password protection, etc).", + "Only forms this API key can access are listed.": "Only forms this API key can access are listed.", + "Replaces the entire blocks array. Use Get Form first to fetch the current blocks so you can merge your change into the full array rather than dropping the rest of the form content.": "Replaces the entire blocks array. Use Get Form first to fetch the current blocks so you can merge your change into the full array rather than dropping the rest of the form content.", + "Replaces the entire settings object. Use Get Form first if you need to preserve other settings.": "Replaces the entire settings object. Use Get Form first if you need to preserve other settings.", + "Filter by submission completion status. Defaults to all.": "Filter by submission completion status. Defaults to all.", + "ISO 8601 date-time. Include submissions on or after this date.": "ISO 8601 date-time. Include submissions on or after this date.", + "ISO 8601 date-time. Include submissions on or before this date.": "ISO 8601 date-time. Include submissions on or before this date.", + "Return submissions after this submission id (cursor-style pagination).": "Return submissions after this submission id (cursor-style pagination).", + "Submissions per page (max 500). Defaults to 50.": "Submissions per page (max 500). Defaults to 50.", + "Obtain from List Submissions or the New Submission trigger payload.": "Obtain from List Submissions or the New Submission trigger payload.", + "Obtain from List Submissions or Get Submission.": "Obtain from List Submissions or Get Submission.", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.", + "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).", + "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.", + "Blank": "Blank", + "Draft": "Draft", + "Published": "Published", + "All": "All", + "Completed": "Completed", + "Partial": "Partial", + "Today": "Today", + "Yesterday": "Yesterday", + "Last 24 hours": "Last 24 hours", + "Last 7 days": "Last 7 days", + "Last 30 days": "Last 30 days", + "Last 3 months": "Last 3 months", + "Last 6 months": "Last 6 months", + "Last 12 months": "Last 12 months", + "All time": "All time", "GET": "GET", "POST": "POST", "PATCH": "PATCH", @@ -25,6 +127,5 @@ "Form Data": "Form Data", "Raw": "Raw", "New Submission": "New Submission", - "Triggers when a form receives a new submission": "Triggers when a form receives a new submission", - "Form": "Form" + "Triggers when someone submits the selected form": "Triggers when someone submits the selected form" } \ No newline at end of file diff --git a/packages/pieces/community/tally/src/index.ts b/packages/pieces/community/tally/src/index.ts index a8284f0c00f4..2f5ffa717159 100644 --- a/packages/pieces/community/tally/src/index.ts +++ b/packages/pieces/community/tally/src/index.ts @@ -34,7 +34,7 @@ export const tally = createPiece({ displayName: 'Tally', description: 'Receive form submissions from Tally forms', auth: tallyAuth, - minimumSupportedRelease: '0.86.4', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/tally.png', categories: [PieceCategory.FORMS_AND_SURVEYS], authors: ['kishanprmr', 'abuaboud', 'bst1n'], diff --git a/packages/pieces/community/tally/src/lib/auth.ts b/packages/pieces/community/tally/src/lib/auth.ts index 165ab1ac5e79..eeb537610794 100644 --- a/packages/pieces/community/tally/src/lib/auth.ts +++ b/packages/pieces/community/tally/src/lib/auth.ts @@ -5,8 +5,11 @@ import { tallyApiClient } from './common/client'; export const tallyAuth = PieceAuth.SecretText({ displayName: 'API Key', - description: - `You can obtain API key from [Settings page](https://tally.so/settings/api-keys)`, + description: `To get your API key: + +1. Sign in to [Tally](https://tally.so) and open **Settings**. +2. Go to **API keys** and click **Create API key**. +3. Copy the key and paste it below.`, required: true, validate: async ({ auth }) => { const { error } = await tryCatch(() => tallyApiClient.validateApiKey(auth)); diff --git a/packages/pieces/community/tally/src/lib/common/props.ts b/packages/pieces/community/tally/src/lib/common/props.ts index 668117d3256e..0d612efb0d45 100644 --- a/packages/pieces/community/tally/src/lib/common/props.ts +++ b/packages/pieces/community/tally/src/lib/common/props.ts @@ -3,6 +3,7 @@ import { tryCatch } from '@activepieces/pieces-framework'; import { tallyAuth } from '../auth'; import { tallyApiClient } from './client'; +import type { TallyForm } from './types'; export const ANALYTICS_PERIOD_OPTIONS = [ { label: 'Today', value: 'today' }, @@ -19,6 +20,7 @@ export const ANALYTICS_PERIOD_OPTIONS = [ export const formsDropdown = Property.Dropdown({ auth: tallyAuth, displayName: 'Form', + description: 'Only forms this API key can access are listed.', required: true, refreshers: [], async options({ auth }) { @@ -40,10 +42,20 @@ export const formsDropdown = Property.Dropdown({ }; } - const options: DropdownOption[] = forms.map((form) => ({ - label: form.name, - value: form.id, - })); + if (forms.length === 0) { + return { + disabled: false, + options: [], + placeholder: 'No forms found. Create one in Tally first.', + }; + } + + const options: DropdownOption[] = forms + .map((form) => ({ + label: formOptionLabel({ form }), + value: form.id, + })) + .sort((a, b) => a.label.localeCompare(b.label)); return { disabled: false, placeholder: 'Select a form', options }; }, @@ -203,3 +215,19 @@ export const requiredFoldersDropdown = Property.Dropdown [q.id, q])); @@ -69,7 +75,7 @@ export const newSubmissionTrigger = createTrigger({ submissionId: submission.id, respondentId: submission.respondentId, formId: submission.formId, - formName: '', + formName: form.name, createdAt: submission.submittedAt, fields: buildSubmissionFields({ submission, questionById }), })); From 1434692189c40413844a6dffc9aadac0d43d497d Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Fri, 25 Sep 2026 18:42:16 +0300 Subject: [PATCH 6/8] feat(whatsapp): clearer message setup, template preview and paginated dropdowns (#15752) --- .../pieces/community/whatsapp/package.json | 2 +- .../pieces/community/whatsapp/src/index.ts | 21 +-- .../src/lib/actions/send-from-template.ts | 5 +- .../whatsapp/src/lib/actions/send-media.ts | 17 ++- .../whatsapp/src/lib/actions/send-message.ts | 5 +- .../pieces/community/whatsapp/src/lib/auth.ts | 20 +-- .../whatsapp/src/lib/common/utils.ts | 137 +++++++++++++----- 7 files changed, 128 insertions(+), 79 deletions(-) diff --git a/packages/pieces/community/whatsapp/package.json b/packages/pieces/community/whatsapp/package.json index 620a0f04105e..df9c286165aa 100644 --- a/packages/pieces/community/whatsapp/package.json +++ b/packages/pieces/community/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-whatsapp", - "version": "0.2.7", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/whatsapp/src/index.ts b/packages/pieces/community/whatsapp/src/index.ts index af0d2c0c27a6..9cfa156a59ac 100644 --- a/packages/pieces/community/whatsapp/src/index.ts +++ b/packages/pieces/community/whatsapp/src/index.ts @@ -1,29 +1,16 @@ -import { createPiece, PieceAuth, Property } from '@activepieces/pieces-framework'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; import { sendMessage } from './lib/actions/send-message'; import { sendMedia } from './lib/actions/send-media'; import { sendTemplateMessageAction } from './lib/actions/send-from-template'; import { whatsappAuth } from './lib/auth'; -const markdown = ` -To Obtain a Phone Number ID and a Permanent System User Access Token, follow these steps: - -1. Go to https://developers.facebook.com/ -2. Make a new app, Select Other for usecase. -3. Choose Business as the type of app. -4. Add new Product -> WhatsApp. -5. Navigate to WhatsApp Settings > API Setup. -6. Copy the Business Account ID. -7. Login to your [Meta Business Manager](https://business.facebook.com/). -8. Click on Settings. -9. Create a new System User with access over the app and copy the access token. -`; - export const whatsapp = createPiece({ displayName: 'WhatsApp Business', - description: 'Manage your WhatsApp business account', + description: 'Send text, media and template messages from your WhatsApp Business number.', auth: whatsappAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/whatsapp.png', + categories: [PieceCategory.COMMUNICATION], authors: ['LevwTech', 'kishanprmr'], actions: [sendMessage, sendMedia, sendTemplateMessageAction], triggers: [], diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts index 0230aee0ac5c..d7b1d8765ff0 100644 --- a/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts +++ b/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts @@ -8,14 +8,15 @@ export const sendTemplateMessageAction = createAction({ name: 'send-template-message', classification: 'WRITE', displayName: 'Send Template Message', - description: 'Sends a template message.', + description: 'Send an approved template, filling in its placeholders.', audience: 'both', aiMetadata: { description: 'Sends a pre-approved WhatsApp message template to a recipient, filling its header, body, and button placeholders from the supplied fields. Choose this to initiate conversations outside the 24-hour customer service window or for notifications/marketing where a registered template is required; the template must already be approved in the WhatsApp Business account. Requires the sender phone number ID, recipient phone number, and the template ID (its name and language are resolved automatically). Not idempotent — each call delivers a new message.', idempotent: false }, props: { phone_number_id: commonProps.phone_number_id, to: Property.ShortText({ displayName: 'To', - description: 'Recipient phone number.', + description: "Recipient's phone number in international format.", + placeholder: '15551234567', required: true, }), message_template_id: commonProps.message_template_id, diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts index 34b6c1c83fe6..a827f4ed1e33 100644 --- a/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts +++ b/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts @@ -13,20 +13,21 @@ export const sendMedia = createAction({ name: 'sendMedia', classification: 'WRITE', displayName: 'Send Media', - description: 'Send a media message through WhatsApp', + description: 'Send an image, video, audio, document or sticker by URL.', audience: 'both', aiMetadata: { description: 'Sends an image, video, audio, document, or sticker to a WhatsApp recipient by referencing the media via a public URL. Choose this when the message payload is a file rather than plain text; captions are supported for media types that allow them and a filename can be set for documents. Requires the sender phone number ID, recipient phone number, media type, and a reachable media URL; subject to WhatsApp messaging-window rules. Not idempotent — each call delivers a new message.', idempotent: false }, props: { phone_number_id: commonProps.phone_number_id, to: Property.ShortText({ displayName: 'To', - description: 'The recipient of the message', + description: "Recipient's phone number in international format.", + placeholder: '15551234567', required: true, }), type: Property.Dropdown({ auth: whatsappAuth, - displayName: 'Type', - description: 'The type of media to send', + displayName: 'Media Type', + description: 'What kind of file the URL points to.', required: true, options: async () => { return { @@ -40,18 +41,20 @@ export const sendMedia = createAction({ }), media: Property.ShortText({ displayName: 'Media URL', - description: 'The URL of the media to send', + description: 'Public link WhatsApp downloads when it sends the message.', + placeholder: 'https://example.com/photo.jpg', required: true, }), caption: Property.LongText({ displayName: 'Caption', - description: 'A caption for the media', + description: 'Text shown under the media. Ignored for audio and stickers.', required: false, }), filename: Property.LongText({ displayName: 'Filename', - description: 'Filename of the document to send', + description: 'Documents only: the file name the recipient sees.', required: false, + advanced: true, }), }, async run(context) { diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts index ab8c12bb16f5..b5743ad01e7a 100644 --- a/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts +++ b/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts @@ -15,12 +15,13 @@ export const sendMessage = createAction({ phone_number_id: commonProps.phone_number_id, to: Property.ShortText({ displayName: 'To', - description: 'The recipient of the message', + description: "Recipient's phone number in international format.", + placeholder: '15551234567', required: true, }), text: Property.LongText({ displayName: 'Message', - description: 'The message to send', + description: 'Plain text, up to 4096 characters.', required: true, }), }, diff --git a/packages/pieces/community/whatsapp/src/lib/auth.ts b/packages/pieces/community/whatsapp/src/lib/auth.ts index a41ebd90cd58..2248f4b7d633 100644 --- a/packages/pieces/community/whatsapp/src/lib/auth.ts +++ b/packages/pieces/community/whatsapp/src/lib/auth.ts @@ -1,17 +1,12 @@ import { PieceAuth, Property } from '@activepieces/pieces-framework'; const markdown = ` -To Obtain a Phone Number ID and a Permanent System User Access Token, follow these steps: +You need the WhatsApp Business Account ID and a permanent System User access token from Meta. -1. Go to https://developers.facebook.com/ -2. Make a new app, Select Other for usecase. -3. Choose Business as the type of app. -4. Add new Product -> WhatsApp. -5. Navigate to WhatsApp Settings > API Setup. -6. Copy the Business Account ID. -7. Login to your [Meta Business Manager](https://business.facebook.com/). -8. Click on Settings. -9. Create a new System User with access over the app and copy the access token. +1. Open [Meta for Developers](https://developers.facebook.com/) and create a **Business** app (use case **Other**). +2. Add the **WhatsApp** product to the app. +3. Go to **WhatsApp > API Setup** and copy the **WhatsApp Business Account ID**. +4. In [Meta Business Manager](https://business.facebook.com/) open **Settings > System Users**, add a system user with access to the app, and generate a permanent token with the **whatsapp_business_messaging** and **whatsapp_business_management** permissions. `; export const whatsappAuth = PieceAuth.CustomAuth({ @@ -20,12 +15,13 @@ export const whatsappAuth = PieceAuth.CustomAuth({ props: { access_token: PieceAuth.SecretText({ displayName: 'System User Access Token', - description: 'The system user access token of your WhatsApp business account.', + description: 'Permanent token of a System User with access to the app.', required: true, }), businessAccountId: Property.ShortText({ displayName: 'Business Account ID', - description: 'The business account ID of your WhatsApp business account.', + description: 'Found under WhatsApp > API Setup in your Meta app.', + placeholder: '102290129340398', required: true, }), }, diff --git a/packages/pieces/community/whatsapp/src/lib/common/utils.ts b/packages/pieces/community/whatsapp/src/lib/common/utils.ts index a4d6a0074d2e..658cd26d99bd 100644 --- a/packages/pieces/community/whatsapp/src/lib/common/utils.ts +++ b/packages/pieces/community/whatsapp/src/lib/common/utils.ts @@ -7,9 +7,9 @@ import { import { whatsappAuth } from '../auth'; import { Property, - PiecePropValueSchema, DynamicPropsValue, DropdownOption, + MarkdownVariant, } from '@activepieces/pieces-framework'; export const supportedMediaTypes = ['image', 'audio', 'document', 'sticker', 'video']; @@ -20,14 +20,14 @@ export const mediaTypeSupportsCaption = (type: string) => export const commonProps = { phone_number_id: Property.Dropdown({ auth: whatsappAuth, - displayName: 'Phone Number ID', - description: 'Phone number ID that will be used to send the message.', + displayName: 'From Phone Number', + description: 'The business number the message is sent from.', refreshers: [], required: true, options: async ({ auth }) => { if (!auth) { return { - placeholder: 'Please connect account first', + placeholder: 'Please connect your account first', disabled: true, options: [], }; @@ -38,16 +38,16 @@ export const commonProps = { const options: DropdownOption[] = []; let hasMore = false; - let cursor; + let cursor: string | undefined; do { const qs: QueryParams = { fields: 'verified_name,id,display_phone_number', - limit: '1', + limit: '100', }; if (cursor) qs['after'] = cursor; - const response = await httpClient.sendRequest({ + const response = await httpClient.sendRequest({ method: HttpMethod.GET, url: `https://graph.facebook.com/v20.0/${authValue.businessAccountId}/phone_numbers`, authentication: { @@ -57,22 +57,32 @@ export const commonProps = { queryParams: qs, }); - for (const phoneNumber of response.body.data) { + for (const phoneNumber of response.body.data ?? []) { options.push({ - label: `${phoneNumber.verified_name as string} : ${ - phoneNumber.display_phone_number as string - }`, - value: phoneNumber.id as string, + label: `${phoneNumber.verified_name} (${phoneNumber.display_phone_number})`, + value: phoneNumber.id, }); } - if (response.body.paging.next) { - (hasMore = true), (cursor = response.body.paging.cursors.after); + const nextPage = response.body.paging?.next; + const nextCursor = response.body.paging?.cursors?.after; + + if (nextPage && nextCursor) { + hasMore = true; + cursor = nextCursor; } else { hasMore = false; } } while (hasMore); + if (options.length === 0) { + return { + placeholder: 'No phone numbers found in this account', + disabled: false, + options: [], + }; + } + return { disabled: false, options, @@ -80,14 +90,15 @@ export const commonProps = { }, }), message_template_id: Property.Dropdown({ - displayName: 'Message Template ID', + displayName: 'Template', + description: 'Only templates approved by WhatsApp are delivered.', refreshers: [], required: true, auth: whatsappAuth, options: async ({ auth }) => { if (!auth) { return { - placeholder: 'Please connect account first', + placeholder: 'Please connect your account first', disabled: true, options: [], }; @@ -98,16 +109,16 @@ export const commonProps = { const options: DropdownOption[] = []; let hasMore = false; - let cursor; + let cursor: string | undefined; do { const qs: QueryParams = { fields: 'id,name,language', - limit: '1', + limit: '100', }; if (cursor) qs['after'] = cursor; - const response = await httpClient.sendRequest({ + const response = await httpClient.sendRequest({ method: HttpMethod.GET, url: `https://graph.facebook.com/v20.0/${authValue.businessAccountId}/message_templates`, authentication: { @@ -117,20 +128,32 @@ export const commonProps = { queryParams: qs, }); - for (const template of response.body.data) { + for (const template of response.body.data ?? []) { options.push({ - label: `${template.name as string} (${template.language as string})`, - value: template.id as string, + label: `${template.name} (${template.language})`, + value: template.id, }); } - if (response.body.paging.next) { - (hasMore = true), (cursor = response.body.paging.cursors.after); + const nextPage = response.body.paging?.next; + const nextCursor = response.body.paging?.cursors?.after; + + if (nextPage && nextCursor) { + hasMore = true; + cursor = nextCursor; } else { hasMore = false; } } while (hasMore); + if (options.length === 0) { + return { + placeholder: 'No message templates found', + disabled: false, + options: [], + }; + } + return { disabled: false, options, @@ -140,17 +163,18 @@ export const commonProps = { message_template_fields: Property.DynamicProperties({ displayName: 'Template Fields', + description: 'Fill in each placeholder of the selected template.', refreshers: ['message_template_id'], required: true, auth: whatsappAuth, - props: async ({ auth, message_template_id }) => { + props: async ({ auth, message_template_id }) => { if (!auth) return {}; if (!message_template_id) return {}; const authValue = auth.props; - const templateId = message_template_id as unknown as string; + const templateId = String(message_template_id); - const response = await httpClient.sendRequest({ + const response = await httpClient.sendRequest({ url: `https://graph.facebook.com/v20.0/${templateId}`, method: HttpMethod.GET, authentication: { @@ -163,13 +187,11 @@ export const commonProps = { const headerComponentFields: DynamicPropsValue = {}; const buttonComponentFields: DynamicPropsValue = {}; - for (const component of response.body.components) { + for (const component of response.body.components ?? []) { if (component.type === 'BODY') { - // https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components#syntax bodyComponentFields['BODY_markdown'] = Property.MarkDown({ - value: ` - **Body :** - ${component.text}`, + value: `**Body**\n\n${escapeTemplatePlaceholders(component.text ?? '')}`, + variant: MarkdownVariant.BORDERLESS, }); const bodyTextVariables = component.text?.match(/{{(\d+)}}/g) ?? []; @@ -181,11 +203,9 @@ export const commonProps = { }); } } else if (component.type === 'HEADER' && component.format === 'TEXT') { - // https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components#text-headers headerComponentFields['HEADER_markdown'] = Property.MarkDown({ - value: ` - **Header :** - ${component.text}`, + value: `**Header**\n\n${escapeTemplatePlaceholders(component.text ?? '')}`, + variant: MarkdownVariant.BORDERLESS, }); const headerTextVariables = component.text?.match(/{{(\d+)}}/g) ?? []; @@ -197,8 +217,7 @@ export const commonProps = { }); } } else if (component.type === 'BUTTONS') { - // https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components#url-buttons - for (const button of component.buttons) { + for (const button of component.buttons ?? []) { if (button.type === 'URL') { const buttonURLTextVariables = button.url?.match(/{{(\d+)}}/g) ?? []; @@ -223,3 +242,45 @@ export const commonProps = { }, }), }; + +function escapeTemplatePlaceholders(text: string) { + return text.replace(/{{(\d+)}}/g, '`{`{$1`}`}'); +} + +type Paging = { + next?: string; + cursors?: { + after?: string; + }; +}; + +type PhoneNumbersPage = { + data?: { + id: string; + verified_name: string; + display_phone_number: string; + }[]; + paging?: Paging; +}; + +type TemplatesPage = { + data?: { + id: string; + name: string; + language: string; + }[]; + paging?: Paging; +}; + +type TemplateDetails = { + components?: { + type: string; + format?: string; + text?: string; + buttons?: { + type: string; + text: string; + url?: string; + }[]; + }[]; +}; From 61774484fd1c4f19d932138642686b072fe3633b Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Fri, 25 Sep 2026 18:42:51 +0300 Subject: [PATCH 7/8] feat(notion): improve the step forms (#15397) --- bun.lock | 2 +- packages/pieces/community/notion/package.json | 2 +- .../notion/src/i18n/translation.json | 90 ++++++++++--------- packages/pieces/community/notion/src/index.ts | 2 +- .../notion/src/lib/actions/add-comment.ts | 5 +- .../notion/src/lib/actions/append-to-page.ts | 3 +- .../src/lib/actions/archive-database-item.ts | 2 +- .../src/lib/actions/create-database-item.ts | 4 +- .../notion/src/lib/actions/create-page.ts | 12 ++- .../notion/src/lib/actions/find-item.ts | 13 ++- .../notion/src/lib/actions/find-page.ts | 18 ++-- .../src/lib/actions/get-page-comments.ts | 2 +- .../lib/actions/get-page-or-block-children.ts | 16 ++-- .../src/lib/actions/list-database-pages.ts | 22 ++++- .../notion/src/lib/actions/list-databases.ts | 11 ++- .../src/lib/actions/restore-database-item.ts | 2 +- .../src/lib/actions/retrieve-database.ts | 2 +- .../src/lib/actions/update-database-item.ts | 2 +- .../pieces/community/notion/src/lib/auth.ts | 7 +- .../community/notion/src/lib/common/index.ts | 57 ++++++------ .../notion/src/lib/triggers/new-comment.ts | 2 +- .../notion/src/lib/triggers/updated-page.ts | 2 +- 22 files changed, 159 insertions(+), 119 deletions(-) diff --git a/bun.lock b/bun.lock index ba264c0f95ba..5857f496cfd6 100644 --- a/bun.lock +++ b/bun.lock @@ -6355,7 +6355,7 @@ }, "packages/pieces/community/notion": { "name": "@activepieces/piece-notion", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/notion/package.json b/packages/pieces/community/notion/package.json index 5540f5de3a21..73f1f1425e8f 100644 --- a/packages/pieces/community/notion/package.json +++ b/packages/pieces/community/notion/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-notion", - "version": "0.7.1", + "version": "0.7.2", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/notion/src/i18n/translation.json b/packages/pieces/community/notion/src/i18n/translation.json index f5eb4a93f7bc..2797862930bb 100644 --- a/packages/pieces/community/notion/src/i18n/translation.json +++ b/packages/pieces/community/notion/src/i18n/translation.json @@ -7,7 +7,7 @@ "List Pages": "List Pages", "Create Page": "Create Page", "Append to Page": "Append to Page", - "Get block content": "Get block content", + "Get Block Content": "Get Block Content", "Archive Database Item": "Archive Database Item", "Restore Database Item": "Restore Database Item", "Add Comment": "Add Comment", @@ -29,20 +29,20 @@ "Create Database": "Create Database", "Update Database Schema": "Update Database Schema", "Custom API Call": "Custom API Call", - "Lists Notion databases accessible by the connected account with pagination support.": "Lists Notion databases accessible by the connected account with pagination support.", - "Add a new item to a Notion database with custom field values and optional content. Ideal for creating tasks, records, or entries in structured databases.": "Add a new item to a Notion database with custom field values and optional content. Ideal for creating tasks, records, or entries in structured databases.", - "Update specific fields in a Notion database item. Perfect for maintaining data, tracking changes, or syncing information across systems.": "Update specific fields in a Notion database item. Perfect for maintaining data, tracking changes, or syncing information across systems.", - "Searches for an item in database by field.": "Searches for an item in database by field.", - "Lists pages in a Notion database with optional field filters and pagination.": "Lists pages in a Notion database with optional field filters and pagination.", - "Create a new Notion page as a sub-page with custom title and content. Perfect for organizing documentation, notes, or creating structured page hierarchies.": "Create a new Notion page as a sub-page with custom title and content. Perfect for organizing documentation, notes, or creating structured page hierarchies.", + "List the databases shared with your integration, newest edits first.": "List the databases shared with your integration, newest edits first.", + "Add an item to a database and set its fields.": "Add an item to a database and set its fields.", + "Change field values on an existing database item.": "Change field values on an existing database item.", + "Find items in a database whose fields match exact values.": "Find items in a database whose fields match exact values.", + "List pages in a database, optionally filtered by field values.": "List pages in a database, optionally filtered by field values.", + "Create a page inside another page, with a title and plain text.": "Create a page inside another page, with a title and plain text.", "Appends content to the end of a page.": "Appends content to the end of a page.", - "Retrieve the actual content of a page (represented by blocks).": "Retrieve the actual content of a page (represented by blocks).", - "Archive (soft-delete) a database item without permanently removing it. Archived items can be restored later if needed.": "Archive (soft-delete) a database item without permanently removing it. Archived items can be restored later if needed.", - "Restore an archived database item back to active status. Perfect for recovering accidentally archived tasks, projects, or records.": "Restore an archived database item back to active status. Perfect for recovering accidentally archived tasks, projects, or records.", - "Add a comment to any Notion page to start discussions, provide feedback, or leave notes for team collaboration.": "Add a comment to any Notion page to start discussions, provide feedback, or leave notes for team collaboration.", - "Get detailed information about a Notion database including all its properties, field types, and configuration. Perfect for building dynamic forms, validation rules, or understanding database schemas.": "Get detailed information about a Notion database including all its properties, field types, and configuration. Perfect for building dynamic forms, validation rules, or understanding database schemas.", - "Retrieve all comments from a Notion page, organized by discussion threads. Perfect for tracking feedback, managing reviews, or monitoring page discussions.": "Retrieve all comments from a Notion page, organized by discussion threads. Perfect for tracking feedback, managing reviews, or monitoring page discussions.", - "Search for Notion pages by title with flexible matching options. Perfect for finding specific pages, building page references, or creating automated workflows based on page discovery.": "Search for Notion pages by title with flexible matching options. Perfect for finding specific pages, building page references, or creating automated workflows based on page discovery.", + "Read a page's content as blocks or as Markdown.": "Read a page's content as blocks or as Markdown.", + "Archive a database item. It can be restored later.": "Archive a database item. It can be restored later.", + "Restore an archived database item.": "Restore an archived database item.", + "Add a comment to a page.": "Add a comment to a page.", + "Get a database's properties, field types and options.": "Get a database's properties, field types and options.", + "Get all comments on a page, grouped by thread.": "Get all comments on a page, grouped by thread.", + "Find pages by title, with exact or partial matching.": "Find pages by title, with exact or partial matching.", "Creates a new sub-page under an existing Notion page, with a title and optional markdown body.": "Creates a new sub-page under an existing Notion page, with a title and optional markdown body.", "Fetches a single page's metadata and property values by id (a database row is a page).": "Fetches a single page's metadata and property values by id (a database row is a page).", "Archives (trashes) or restores any page or database row by id via its archived flag.": "Archives (trashes) or restores any page or database row by id via its archived flag.", @@ -72,22 +72,20 @@ "Fields": "Fields", "Content": "Content", "Database Item": "Database Item", - "Page": "Page", - "Title": "Title", - "Page or parent block ID": "Page or parent block ID", "Markdown": "Markdown", - "Dynamic properties": "Dynamic properties", + "Parent Page": "Parent Page", + "Title": "Title", + "Page": "Page", + "Page or Block ID": "Page or Block ID", + "Dynamic Properties": "Dynamic Properties", "Archived Item": "Archived Item", "Comment Text": "Comment Text", - "Page Title": "Page Title", "Exact Match": "Exact Match", - "Maximum Results": "Maximum Results", "Parent Page ID": "Parent Page ID", "Page ID": "Page ID", "Archived": "Archived", "New Parent Page ID": "New Parent Page ID", "New Parent Database ID": "New Parent Database ID", - "Page or Block ID": "Page or Block ID", "Depth": "Depth", "Block ID": "Block ID", "Block Type": "Block Type", @@ -95,6 +93,7 @@ "Language": "Language", "Query": "Query", "Type Filter": "Type Filter", + "Maximum Results": "Maximum Results", "Page Size": "Page Size", "Start Cursor": "Start Cursor", "User ID": "User ID", @@ -110,27 +109,28 @@ "Query Parameters": "Query Parameters", "Body Type": "Body Type", "Body": "Body", - "Response is Binary ?": "Response is Binary ?", - "No Error on Failure": "No Error on Failure", - "Timeout (in seconds)": "Timeout (in seconds)", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", "Follow redirects": "Follow redirects", - "Number of databases to retrieve (1-100).": "Number of databases to retrieve (1-100).", - "Pagination cursor returned from a previous execution.": "Pagination cursor returned from a previous execution.", - "Choose the Notion database you want to work with from your workspace": "Choose the Notion database you want to work with from your workspace", - "The content you want to append to your item.": "The content you want to append to your item.", - "Select the item you want to update": "Select the item you want to update", - "Number of pages to return (1-100).": "Number of pages to return (1-100).", - "Pagination cursor from a previous run.": "Pagination cursor from a previous run.", - "Choose the Notion page you want to work with. This list shows your 100 most recently edited pages for easy selection.": "Choose the Notion page you want to work with. This list shows your 100 most recently edited pages for easy selection.", - "The title of the page.": "The title of the page.", - "The content of the page.": "The content of the page.", - "The content you want to append. You can use markdown formatting.": "The content you want to append. You can use markdown formatting.", - "Convert Notion JSON blocks to Markdown": "Convert Notion JSON blocks to Markdown", - "Choose which archived item to restore from the selected database": "Choose which archived item to restore from the selected database", - "Enter your comment text. Supports plain text and will be posted as a new comment thread on the selected page.": "Enter your comment text. Supports plain text and will be posted as a new comment thread on the selected page.", - "Enter the page title or part of the title you want to search for": "Enter the page title or part of the title you want to search for", - "Enable this to find pages with exactly the same title. Disable for partial matching (finds pages containing your search term).": "Enable this to find pages with exactly the same title. Disable for partial matching (finds pages containing your search term).", - "How many pages to return at most (between 1 and 100)": "How many pages to return at most (between 1 and 100)", + "How many databases to return, up to 100.": "How many databases to return, up to 100.", + "Next-page cursor from a previous run's output.": "Next-page cursor from a previous run's output.", + "Only databases shared with your integration are listed.": "Only databases shared with your integration are listed.", + "Plain text added to the item's page body.": "Plain text added to the item's page body.", + "Items in the selected database, listed by title.": "Items in the selected database, listed by title.", + "Pick a database, then fill one or more of its fields. Items must match every value exactly.": "Pick a database, then fill one or more of its fields. Items must match every value exactly.", + "How many pages to return, up to 100.": "How many pages to return, up to 100.", + "Pick a database, then fill any of its fields below to filter. Empty fields are ignored.": "Pick a database, then fill any of its fields below to filter. Empty fields are ignored.", + "The new page is created inside this page.": "The new page is created inside this page.", + "Plain text body. Markdown is not rendered.": "Plain text body. Markdown is not rendered.", + "Your 100 most recently edited pages shared with the integration.": "Your 100 most recently edited pages shared with the integration.", + "Markdown is rendered into Notion blocks.": "Markdown is rendered into Notion blocks.", + "Paste the ID, or map a page from a previous step.": "Paste the ID, or map a page from a previous step.", + "Return the content as Markdown instead of blocks.": "Return the content as Markdown instead of blocks.", + "Archived items in the selected database.": "Archived items in the selected database.", + "Posted as a new comment thread on the page.": "Posted as a new comment thread on the page.", + "Full title, or part of it.": "Full title, or part of it.", + "Only return pages whose title equals the search exactly.": "Only return pages whose title equals the search exactly.", "The id of the existing page to create the new page under. Resolve via notion_search.": "The id of the existing page to create the new page under. Resolve via notion_search.", "The title of the new page.": "The title of the new page.", "Optional body content for the page. Markdown is supported and converted to Notion blocks.": "Optional body content for the page. Markdown is supported and converted to Notion blocks.", @@ -183,7 +183,11 @@ "The page id of the database row to archive. Resolve it via notion_query_database or notion_find_database_item.": "The page id of the database row to archive. Resolve it via notion_query_database or notion_find_database_item.", "The page id of the archived database row to restore. Resolve it via notion_query_database (with an archived filter) or from the response of notion_archive_database_item.": "The page id of the archived database row to restore. Resolve it via notion_query_database (with an archived filter) or from the response of notion_archive_database_item.", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.", + "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).", + "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.", "Paragraph": "Paragraph", "Heading 1": "Heading 1", "Heading 2": "Heading 2", @@ -213,6 +217,6 @@ "Updated Page": "Updated Page", "Triggers when an item is added to a database.": "Triggers when an item is added to a database.", "Triggers when an item is updated in a database.": "Triggers when an item is updated in a database.", - "Triggers whenever someone adds a new comment to a specific Notion page. Perfect for notifications, review workflows, or automated responses to team feedback.": "Triggers whenever someone adds a new comment to a specific Notion page. Perfect for notifications, review workflows, or automated responses to team feedback.", - "Triggers whenever any page in your Notion workspace is modified or updated. Ideal for syncing content changes, backup processes, or notifying teams about documentation updates.": "Triggers whenever any page in your Notion workspace is modified or updated. Ideal for syncing content changes, backup processes, or notifying teams about documentation updates." + "Triggers when a comment is added to a page.": "Triggers when a comment is added to a page.", + "Triggers when any page shared with the integration is edited.": "Triggers when any page shared with the integration is edited." } \ No newline at end of file diff --git a/packages/pieces/community/notion/src/index.ts b/packages/pieces/community/notion/src/index.ts index 92d80cde1824..3be2593e9f44 100644 --- a/packages/pieces/community/notion/src/index.ts +++ b/packages/pieces/community/notion/src/index.ts @@ -51,7 +51,7 @@ export const notion = createPiece({ description: 'The all-in-one workspace', logoUrl: 'https://cdn.activepieces.com/pieces/notion.png', categories: [PieceCategory.PRODUCTIVITY], - minimumSupportedRelease: '0.86.4', + minimumSupportedRelease: '0.88.2', authors: [ 'ShayPunter', 'kishanprmr', diff --git a/packages/pieces/community/notion/src/lib/actions/add-comment.ts b/packages/pieces/community/notion/src/lib/actions/add-comment.ts index 159d81bd7cd1..b05a75c4d5cc 100644 --- a/packages/pieces/community/notion/src/lib/actions/add-comment.ts +++ b/packages/pieces/community/notion/src/lib/actions/add-comment.ts @@ -10,7 +10,7 @@ export const addComment = createAction({ classification: 'WRITE', displayName: 'Add Comment', description: - 'Add a comment to any Notion page to start discussions, provide feedback, or leave notes for team collaboration.', + 'Add a comment to a page.', audience: 'human', aiMetadata: { description: @@ -21,8 +21,7 @@ export const addComment = createAction({ page_id: notionCommon.page, comment_text: Property.LongText({ displayName: 'Comment Text', - description: - 'Enter your comment text. Supports plain text and will be posted as a new comment thread on the selected page.', + description: 'Posted as a new comment thread on the page.', required: true, }), }, diff --git a/packages/pieces/community/notion/src/lib/actions/append-to-page.ts b/packages/pieces/community/notion/src/lib/actions/append-to-page.ts index 83264027769e..801267cbdbc3 100644 --- a/packages/pieces/community/notion/src/lib/actions/append-to-page.ts +++ b/packages/pieces/community/notion/src/lib/actions/append-to-page.ts @@ -23,8 +23,7 @@ export const appendToPage = createAction({ pageId: notionCommon.page, content: Property.LongText({ displayName: 'Content', - description: - 'The content you want to append. You can use markdown formatting.', + description: 'Markdown is rendered into Notion blocks.', required: true, }), }, diff --git a/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts b/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts index a324b63d96a1..edb2df2b0e3f 100644 --- a/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts @@ -10,7 +10,7 @@ export const archiveDatabaseItem = createAction({ classification: 'DESTRUCTIVE', displayName: 'Archive Database Item', description: - 'Archive (soft-delete) a database item without permanently removing it. Archived items can be restored later if needed.', + 'Archive a database item. It can be restored later.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/create-database-item.ts b/packages/pieces/community/notion/src/lib/actions/create-database-item.ts index 61b2775cf158..8324e833ed42 100644 --- a/packages/pieces/community/notion/src/lib/actions/create-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/create-database-item.ts @@ -15,7 +15,7 @@ export const createDatabaseItem = createAction({ classification: 'WRITE', displayName: 'Create Database Item', description: - 'Add a new item to a Notion database with custom field values and optional content. Ideal for creating tasks, records, or entries in structured databases.', + 'Add an item to a database and set its fields.', audience: 'human', aiMetadata: { description: @@ -27,7 +27,7 @@ export const createDatabaseItem = createAction({ databaseFields: notionCommon.databaseFields, content: Property.LongText({ displayName: 'Content', - description: 'The content you want to append to your item.', + description: "Plain text added to the item's page body.", required: false, }), }, diff --git a/packages/pieces/community/notion/src/lib/actions/create-page.ts b/packages/pieces/community/notion/src/lib/actions/create-page.ts index 2a846caacc57..504b6c480ca0 100644 --- a/packages/pieces/community/notion/src/lib/actions/create-page.ts +++ b/packages/pieces/community/notion/src/lib/actions/create-page.ts @@ -11,7 +11,7 @@ export const createPage = createAction({ classification: 'WRITE', displayName: 'Create Page', description: - 'Create a new Notion page as a sub-page with custom title and content. Perfect for organizing documentation, notes, or creating structured page hierarchies.', + 'Create a page inside another page, with a title and plain text.', audience: 'human', aiMetadata: { description: @@ -19,15 +19,19 @@ export const createPage = createAction({ idempotent: false, }, props: { - pageId: notionCommon.page, + pageId: { + ...notionCommon.page, + displayName: 'Parent Page', + description: 'The new page is created inside this page.', + }, title: Property.ShortText({ displayName: 'Title', - description: 'The title of the page.', + placeholder: 'e.g. Meeting notes', required: false, }), content: Property.LongText({ displayName: 'Content', - description: 'The content of the page.', + description: 'Plain text body. Markdown is not rendered.', required: false, }), }, diff --git a/packages/pieces/community/notion/src/lib/actions/find-item.ts b/packages/pieces/community/notion/src/lib/actions/find-item.ts index 248ac291621d..c600720e6aa4 100644 --- a/packages/pieces/community/notion/src/lib/actions/find-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/find-item.ts @@ -1,5 +1,9 @@ import { notionAuth } from '../auth'; -import { createAction } from '@activepieces/pieces-framework'; +import { + createAction, + MarkdownVariant, + Property, +} from '@activepieces/pieces-framework'; import { getNotionToken, notionCommon } from '../common'; import { Client } from '@notionhq/client'; import { notionFindDatabaseItemActionOutputSchema } from '../output-schemas'; @@ -9,7 +13,7 @@ export const findDatabaseItem = createAction({ name: 'notion-find-database-item', classification: 'SEARCH', displayName: 'Find Database Item', - description: 'Searches for an item in database by field.', + description: 'Find items in a database whose fields match exact values.', audience: 'human', aiMetadata: { description: @@ -18,6 +22,11 @@ export const findDatabaseItem = createAction({ }, props: { database_id: notionCommon.database_id, + filter_hint: Property.MarkDown({ + value: + 'Pick a database, then fill one or more of its fields. Items must match every value exactly.', + variant: MarkdownVariant.INFO, + }), filterDatabaseFields: notionCommon.filterDatabaseFields, }, outputSchema: notionFindDatabaseItemActionOutputSchema, diff --git a/packages/pieces/community/notion/src/lib/actions/find-page.ts b/packages/pieces/community/notion/src/lib/actions/find-page.ts index c50b93852333..1a170a51de1e 100644 --- a/packages/pieces/community/notion/src/lib/actions/find-page.ts +++ b/packages/pieces/community/notion/src/lib/actions/find-page.ts @@ -10,7 +10,7 @@ export const findPage = createAction({ classification: 'SEARCH', displayName: 'Find Page', description: - 'Search for Notion pages by title with flexible matching options. Perfect for finding specific pages, building page references, or creating automated workflows based on page discovery.', + 'Find pages by title, with exact or partial matching.', audience: 'human', aiMetadata: { description: @@ -19,23 +19,27 @@ export const findPage = createAction({ }, props: { title: Property.ShortText({ - displayName: 'Page Title', - description: - 'Enter the page title or part of the title you want to search for', + displayName: 'Title', + description: 'Full title, or part of it.', required: true, + placeholder: 'e.g. Meeting notes', }), exact_match: Property.Checkbox({ displayName: 'Exact Match', description: - 'Enable this to find pages with exactly the same title. Disable for partial matching (finds pages containing your search term).', + 'Only return pages whose title equals the search exactly.', required: false, defaultValue: false, }), limit: Property.Number({ - displayName: 'Maximum Results', - description: 'How many pages to return at most (between 1 and 100)', + displayName: 'Limit', + description: 'How many pages to return, up to 100.', required: false, defaultValue: 10, + display: 'stepper', + min: 1, + max: 100, + step: 1, }), }, outputSchema: findPageActionOutputSchema, diff --git a/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts b/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts index 07bd403068ee..6e7471d7ff17 100644 --- a/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts +++ b/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts @@ -10,7 +10,7 @@ export const getPageComments = createAction({ classification: 'READ', displayName: 'Get Page Comments', description: - 'Retrieve all comments from a Notion page, organized by discussion threads. Perfect for tracking feedback, managing reviews, or monitoring page discussions.', + 'Get all comments on a page, grouped by thread.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/get-page-or-block-children.ts b/packages/pieces/community/notion/src/lib/actions/get-page-or-block-children.ts index 0ff9e711c0d0..ba8ed6117a66 100644 --- a/packages/pieces/community/notion/src/lib/actions/get-page-or-block-children.ts +++ b/packages/pieces/community/notion/src/lib/actions/get-page-or-block-children.ts @@ -14,8 +14,8 @@ export const getPageOrBlockChildren = createAction({ auth: notionAuth, name: 'getPageOrBlockChildren', classification: 'READ', - displayName: 'Get block content', - description: 'Retrieve the actual content of a page (represented by blocks).', + displayName: 'Get Block Content', + description: "Read a page's content as blocks or as Markdown.", audience: 'human', aiMetadata: { description: @@ -24,18 +24,20 @@ export const getPageOrBlockChildren = createAction({ }, props: { parentId: Property.ShortText({ - displayName: 'Page or parent block ID', + displayName: 'Page or Block ID', + description: 'Paste the ID, or map a page from a previous step.', required: true, + placeholder: 'e.g. 1d4805e9774b8056820bc1083bff77e3', }), markdown: Property.Checkbox({ displayName: 'Markdown', - description: 'Convert Notion JSON blocks to Markdown', - required: true, + description: 'Return the content as Markdown instead of blocks.', + required: false, defaultValue: false, }), dynamic: Property.DynamicProperties({ auth: notionAuth, - displayName: 'Dynamic properties', + displayName: 'Dynamic Properties', refreshers: ['markdown'], required: true, props: async ({ markdown }) => { @@ -45,7 +47,7 @@ export const getPageOrBlockChildren = createAction({ const fields: DynamicPropsValue = { depth: Property.Number({ displayName: 'Depth', - description: 'Recursively retrieve children up to this depth', + description: 'How many levels of nested blocks to include.', required: true, defaultValue: 1, }), diff --git a/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts b/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts index c6e57d46b256..ea1e963e9ca1 100644 --- a/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts +++ b/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts @@ -1,4 +1,8 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { + createAction, + MarkdownVariant, + Property, +} from '@activepieces/pieces-framework'; import { Client } from '@notionhq/client'; import { notionAuth } from '../auth'; import { getNotionToken, notionCommon } from '../common'; @@ -12,7 +16,7 @@ export const listDatabasePages = createAction({ classification: 'SEARCH', displayName: 'List Pages', description: - 'Lists pages in a Notion database with optional field filters and pagination.', + 'List pages in a database, optionally filtered by field values.', audience: 'human', aiMetadata: { description: @@ -25,12 +29,22 @@ export const listDatabasePages = createAction({ displayName: 'Limit', required: false, defaultValue: DEFAULT_LIMIT, - description: `Number of pages to return (1-${MAX_LIMIT}).`, + description: 'How many pages to return, up to 100.', + display: 'stepper', + min: 1, + max: MAX_LIMIT, + step: 1, }), cursor: Property.ShortText({ displayName: 'Cursor', required: false, - description: 'Pagination cursor from a previous run.', + description: "Next-page cursor from a previous run's output.", + advanced: true, + }), + filter_hint: Property.MarkDown({ + value: + 'Pick a database, then fill any of its fields below to filter. Empty fields are ignored.', + variant: MarkdownVariant.INFO, }), filterDatabaseFields: notionCommon.filterDatabaseFields, }, diff --git a/packages/pieces/community/notion/src/lib/actions/list-databases.ts b/packages/pieces/community/notion/src/lib/actions/list-databases.ts index 58ebcc2f3994..7a28decaf13d 100644 --- a/packages/pieces/community/notion/src/lib/actions/list-databases.ts +++ b/packages/pieces/community/notion/src/lib/actions/list-databases.ts @@ -12,7 +12,7 @@ export const listDatabases = createAction({ classification: 'SEARCH', displayName: 'List Databases', description: - 'Lists Notion databases accessible by the connected account with pagination support.', + 'List the databases shared with your integration, newest edits first.', audience: 'human', aiMetadata: { description: @@ -22,14 +22,19 @@ export const listDatabases = createAction({ props: { limit: Property.Number({ displayName: 'Limit', - description: `Number of databases to retrieve (1-${MAX_LIMIT}).`, + description: 'How many databases to return, up to 100.', required: false, defaultValue: DEFAULT_LIMIT, + display: 'stepper', + min: 1, + max: MAX_LIMIT, + step: 1, }), cursor: Property.ShortText({ displayName: 'Cursor', - description: 'Pagination cursor returned from a previous execution.', + description: "Next-page cursor from a previous run's output.", required: false, + advanced: true, }), }, async run(context) { diff --git a/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts b/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts index 8bfc37b07aff..beb47986caa8 100644 --- a/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts @@ -10,7 +10,7 @@ export const restoreDatabaseItem = createAction({ classification: 'WRITE', displayName: 'Restore Database Item', description: - 'Restore an archived database item back to active status. Perfect for recovering accidentally archived tasks, projects, or records.', + 'Restore an archived database item.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts b/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts index 7e64f9162854..bce34e65a374 100644 --- a/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts +++ b/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts @@ -15,7 +15,7 @@ export const retrieveDatabase = createAction({ classification: 'READ', displayName: 'Retrieve Database Structure', description: - 'Get detailed information about a Notion database including all its properties, field types, and configuration. Perfect for building dynamic forms, validation rules, or understanding database schemas.', + "Get a database's properties, field types and options.", audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/update-database-item.ts b/packages/pieces/community/notion/src/lib/actions/update-database-item.ts index 27db49b5b3df..9a8602dc9aad 100644 --- a/packages/pieces/community/notion/src/lib/actions/update-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/update-database-item.ts @@ -14,7 +14,7 @@ export const updateDatabaseItem = createAction({ classification: 'WRITE', displayName: 'Update Database Item', description: - 'Update specific fields in a Notion database item. Perfect for maintaining data, tracking changes, or syncing information across systems.', + 'Change field values on an existing database item.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/auth.ts b/packages/pieces/community/notion/src/lib/auth.ts index dff500668d92..965ba0d3abe7 100644 --- a/packages/pieces/community/notion/src/lib/auth.ts +++ b/packages/pieces/community/notion/src/lib/auth.ts @@ -21,12 +21,13 @@ export const notionOAuth2Auth = PieceAuth.OAuth2({ const notionCustomAuth = PieceAuth.CustomAuth({ displayName: 'Access Token', - description: - 'Connect using a Notion Internal Integration Token. Create one at https://www.notion.so/my-integrations.', + description: `1. Open [My integrations](https://www.notion.so/my-integrations) and create an internal integration. +2. Copy its Internal Integration Secret and paste it below. +3. In Notion, open each page or database you want to use and share it with the integration.`, required: true, props: { accessToken: PieceAuth.SecretText({ - displayName: 'Internal Integration Token', + displayName: 'Internal Integration Secret', required: true, }), }, diff --git a/packages/pieces/community/notion/src/lib/common/index.ts b/packages/pieces/community/notion/src/lib/common/index.ts index d6e5f8f105fb..bc65e36f22a9 100644 --- a/packages/pieces/community/notion/src/lib/common/index.ts +++ b/packages/pieces/community/notion/src/lib/common/index.ts @@ -26,14 +26,13 @@ export const notionCommon = { auth: notionAuth, displayName: 'Database', required: true, - description: - 'Choose the Notion database you want to work with from your workspace', + description: 'Only databases shared with your integration are listed.', refreshers: [], options: async ({ auth }) => { if (!auth) { return { disabled: true, - placeholder: 'Please connect your Notion account first', + placeholder: 'Connect your Notion account first', options: [], }; } @@ -61,15 +60,21 @@ export const notionCommon = { database_item_id: Property.Dropdown({ auth: notionAuth, displayName: 'Database Item', - description: 'Select the item you want to update', + description: 'Items in the selected database, listed by title.', required: true, refreshers: ['database_id'], options: async ({ auth, database_id }) => { - if (!auth || !database_id) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your Notion account first', + options: [], + }; + } + if (!database_id) { return { disabled: true, - placeholder: - 'Please connect your Notion account first and select database', + placeholder: 'Select a database first', options: [], }; } @@ -96,16 +101,21 @@ export const notionCommon = { archived_database_item_id: Property.Dropdown({ auth: notionAuth, displayName: 'Archived Item', - description: - 'Choose which archived item to restore from the selected database', + description: 'Archived items in the selected database.', required: true, refreshers: ['database_id'], options: async ({ auth, database_id }) => { - if (!auth || !database_id) { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your Notion account first', + options: [], + }; + } + if (!database_id) { return { disabled: true, - placeholder: - 'Please connect your Notion account first and select a database', + placeholder: 'Select a database first', options: [], }; } @@ -126,7 +136,7 @@ export const notionCommon = { return { disabled: false, options: [], - placeholder: 'No archived items found in this database', + placeholder: 'No archived items in this database', }; } @@ -145,8 +155,7 @@ export const notionCommon = { } catch (error: any) { return { disabled: true, - placeholder: - 'Error loading archived items. Please check your database permissions.', + placeholder: 'Could not load items. Check the integration has access.', options: [], }; } @@ -159,12 +168,7 @@ export const notionCommon = { refreshers: ['database_id'], props: async ({ auth, database_id }) => { if (!auth || !database_id) { - return { - disabled: true, - placeholder: - 'Please connect your Notion account first and select database', - options: [], - }; + return {}; } const fields: DynamicPropsValue = {}; try { @@ -240,12 +244,7 @@ export const notionCommon = { refreshers: ['database_id'], props: async ({ auth, database_id }) => { if (!auth || !database_id) { - return { - disabled: true, - placeholder: - 'Please connect your Notion account first and select database', - options: [], - }; + return {}; } const fields: DynamicPropsValue = {}; try { @@ -318,13 +317,13 @@ export const notionCommon = { displayName: 'Page', required: true, description: - 'Choose the Notion page you want to work with. This list shows your 100 most recently edited pages for easy selection.', + 'Your 100 most recently edited pages shared with the integration.', refreshers: [], options: async ({ auth }) => { if (!auth) { return { disabled: true, - placeholder: 'Please connect your Notion account first', + placeholder: 'Connect your Notion account first', options: [], }; } diff --git a/packages/pieces/community/notion/src/lib/triggers/new-comment.ts b/packages/pieces/community/notion/src/lib/triggers/new-comment.ts index 29462dfd494f..14dc6f10411e 100644 --- a/packages/pieces/community/notion/src/lib/triggers/new-comment.ts +++ b/packages/pieces/community/notion/src/lib/triggers/new-comment.ts @@ -20,7 +20,7 @@ export const newComment = createTrigger({ classification: 'READ', displayName: 'New Comment', description: - 'Triggers whenever someone adds a new comment to a specific Notion page. Perfect for notifications, review workflows, or automated responses to team feedback.', + 'Triggers when a comment is added to a page.', aiMetadata: { description: 'Fires when a new comment is posted on the selected Notion page, emitting the comment. Use to react to feedback or discussion activity on a specific page; requires read-comments capability on the integration.', diff --git a/packages/pieces/community/notion/src/lib/triggers/updated-page.ts b/packages/pieces/community/notion/src/lib/triggers/updated-page.ts index cea02934e641..b84c9f6c981d 100644 --- a/packages/pieces/community/notion/src/lib/triggers/updated-page.ts +++ b/packages/pieces/community/notion/src/lib/triggers/updated-page.ts @@ -20,7 +20,7 @@ export const updatedPage = createTrigger({ classification: 'READ', displayName: 'Updated Page', description: - 'Triggers whenever any page in your Notion workspace is modified or updated. Ideal for syncing content changes, backup processes, or notifying teams about documentation updates.', + 'Triggers when any page shared with the integration is edited.', aiMetadata: { description: 'Fires whenever any page shared with the integration in the Notion workspace is modified, emitting the updated page. Use to track content changes workspace-wide rather than within a single database or page.', From d2b0b096695a60f7292f37dddcc8c1537db97a08 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Fri, 25 Sep 2026 18:49:41 +0300 Subject: [PATCH 8/8] feat(date-helper): rebuild the step forms around plain-language copy (#15281) --- bun.lock | 2 +- packages/pieces/core/date-helper/package.json | 2 +- .../date-helper/src/i18n/translation.json | 87 +++++------ packages/pieces/core/date-helper/src/index.ts | 2 +- .../src/lib/actions/add-subtract-date.ts | 68 +++++--- .../src/lib/actions/date-difference.ts | 41 +++-- .../src/lib/actions/extract-date-parts.ts | 22 +-- .../lib/actions/first-day-of-prior-month.ts | 39 +++-- .../src/lib/actions/format-date.ts | 38 +++-- .../src/lib/actions/get-current-date.ts | 7 +- .../lib/actions/last-day-of-prior-month.ts | 39 +++-- .../src/lib/actions/next-day-of-week.ts | 42 ++--- .../src/lib/actions/next-day-of-year.ts | 47 +++--- .../core/date-helper/src/lib/common/index.ts | 145 ++++++++++++------ 14 files changed, 364 insertions(+), 217 deletions(-) diff --git a/bun.lock b/bun.lock index 5857f496cfd6..a1ec9c4f088c 100644 --- a/bun.lock +++ b/bun.lock @@ -10365,7 +10365,7 @@ }, "packages/pieces/core/date-helper": { "name": "@activepieces/piece-date-helper", - "version": "0.1.35", + "version": "0.1.36", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/core/date-helper/package.json b/packages/pieces/core/date-helper/package.json index 950cf0c774bb..c47c7fe10fae 100644 --- a/packages/pieces/core/date-helper/package.json +++ b/packages/pieces/core/date-helper/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-date-helper", - "version": "0.1.35", + "version": "0.1.36", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/core/date-helper/src/i18n/translation.json b/packages/pieces/core/date-helper/src/i18n/translation.json index ff95d3f42444..ff9791e21d5f 100644 --- a/packages/pieces/core/date-helper/src/i18n/translation.json +++ b/packages/pieces/core/date-helper/src/i18n/translation.json @@ -1,4 +1,5 @@ { + "1694949838": "1694949838", "Manipulate, format, and extract time units for all your date and time needs.": "Manipulate, format, and extract time units for all your date and time needs.", "Get Current Date": "Get Current Date", "Format Date": "Format Date", @@ -11,62 +12,58 @@ "Last Day of Previous Month": "Last Day of Previous Month", "Get the current date": "Get the current date", "Converts a date from one format to another": "Converts a date from one format to another", - "Extract date units ( year , month , day , hour , minute , second , day of week , month name ) from a date": "Extract date units ( year , month , day , hour , minute , second , day of week , month name ) from a date", + "Pull year, month, day, weekday and more out of a date": "Pull year, month, day, weekday and more out of a date", "Get the difference between two dates": "Get the difference between two dates", "Add or subtract time from a date": "Add or subtract time from a date", "Get the date and time of the next day of the week": "Get the date and time of the next day of the week", "Get the date and time of the next day of the year": "Get the date and time of the next day of the year", "Get the date and time of the first day of the previous month": "Get the date and time of the first day of the previous month", "Get the date and time of the last day of the previous month": "Get the date and time of the last day of the previous month", - "To Time Format": "To Time Format", + "Output Format": "Output Format", "Time Zone": "Time Zone", - "Input Date": "Input Date", - "From Time Format": "From Time Format", - "From Time Zone": "From Time Zone", - "To Time Zone": "To Time Zone", - "Unit to Extract": "Unit to Extract", - "Starting Date": "Starting Date", - "Starting date format": "Starting date format", - "Ending Date": "Ending Date", - "Ending date format": "Ending date format", - "Unit": "Unit", + "Date": "Date", + "Format": "Format", + "Date Format": "Date Format", + "Units": "Units", "Expression": "Expression", - "Set Time To (24h format)": "Set Time To (24h format)", + "Set Time": "Set Time", "Use Current Time": "Use Current Time", "Weekday": "Weekday", - "24h Time": "24h Time", + "Time": "Time", "Month": "Month", "Day of Month": "Day of Month", - "Here's what each part of the format (e.g., YYYY) represents:\n\nYYYY : Year (4 digits) - Example: 2023\n\nYY : Year (2 digits) - Example: 23\n\nMMMM : Month (full name) - Example: September\n\nMMM : Month (short name) - Example: Sep\n\nMM : Month (2 digits) - Example: 09\n\nDDDD : Day (full name) - Example: Sunday\n\nDDD : Day (short name) - Example: Sun\n\nDD : Day (2 digits) - Example: 17\n\nHH : Hour (2 digits) - Example: 11\n\nmm : Minute (2 digits) - Example: 23\n\nss : Second (2 digits) - Example: 58\n\nX : Time in Unix form": "Here's what each part of the format (e.g., YYYY) represents:\n\nYYYY : Year (4 digits) - Example: 2023\n\nYY : Year (2 digits) - Example: 23\n\nMMMM : Month (full name) - Example: September\n\nMMM : Month (short name) - Example: Sep\n\nMM : Month (2 digits) - Example: 09\n\nDDDD : Day (full name) - Example: Sunday\n\nDDD : Day (short name) - Example: Sun\n\nDD : Day (2 digits) - Example: 17\n\nHH : Hour (2 digits) - Example: 11\n\nmm : Minute (2 digits) - Example: 23\n\nss : Second (2 digits) - Example: 58\n\nX : Time in Unix format - Example: 1694949838", - "Enter the input date": "Enter the input date", - "Select the unit to extract from the date": "Select the unit to extract from the date", - "Enter the starting date": "Enter the starting date", - "Enter the ending date": "Enter the ending date", - "Select the unit of difference between the two dates": "Select the unit of difference between the two dates", - "Provide an expression to add or subtract using the following units (year , month , day , hour , minute or second).\n \nExamples:\n+ 2 second + 1 hour \n+ 1 year - 3 day - 2 month \n+ 5 minute": "Provide an expression to add or subtract using the following units (year , month , day , hour , minute or second).\n \nExamples:\n+ 2 second + 1 hour \n+ 1 year - 3 day - 2 month \n+ 5 minute", - "Optional: Set a timezone for the calculation to handle DST correctly": "Optional: Set a timezone for the calculation to handle DST correctly", - "Optional: Set the result to a specific time (e.g., \"10:00\" or \"14:30\"). This allows scheduling at a specific time after adding/subtracting dates. Leave empty to keep the calculated time.": "Optional: Set the result to a specific time (e.g., \"10:00\" or \"14:30\"). This allows scheduling at a specific time after adding/subtracting dates. Leave empty to keep the calculated time.", - "If checked, the current time will be used instead of the time from \"Set Time To\" field.": "If checked, the current time will be used instead of the time from \"Set Time To\" field.", - "The weekday that you would like to get the date and time of.": "The weekday that you would like to get the date and time of.", - "The time that you would like to get the date and time of. This must be in 24h format.": "The time that you would like to get the date and time of. This must be in 24h format.", - "If checked, the current time will be used instead of the time specified above.": "If checked, the current time will be used instead of the time specified above.", - "The month that you would like to get the date and time of.": "The month that you would like to get the date and time of.", - "The day of the month that you would like to get the date and time of.": "The day of the month that you would like to get the date and time of.", - "DDD MMM DD YYYY HH:mm:ss (Sun Sep 17 2023 11:23:58)": "DDD MMM DD YYYY HH:mm:ss (Sun Sep 17 2023 11:23:58)", - "DDD MMM DD HH:mm:ss YYYY (Sun Sep 17 11:23:58 2023)": "DDD MMM DD HH:mm:ss YYYY (Sun Sep 17 11:23:58 2023)", - "MMMM DD YYYY HH:mm:ss (September 17 2023 11:23:58)": "MMMM DD YYYY HH:mm:ss (September 17 2023 11:23:58)", - "MMMM DD YYYY (September 17 2023)": "MMMM DD YYYY (September 17 2023)", - "MMM DD YYYY (Sep 17 2023)": "MMM DD YYYY (Sep 17 2023)", - "YYYY-MM-DDTHH:mm:ss (2023-09-17T11:23:58) ": "YYYY-MM-DDTHH:mm:ss (2023-09-17T11:23:58) ", - "YYYY-MM-DD HH:mm:ss (2023-09-17 11:23:58)": "YYYY-MM-DD HH:mm:ss (2023-09-17 11:23:58)", - "YYYY-MM-DD (2023-09-17)": "YYYY-MM-DD (2023-09-17)", - "MM-DD-YYYY (09-17-2023)": "MM-DD-YYYY (09-17-2023)", - "MM/DD/YYYY (09/17/2023)": "MM/DD/YYYY (09/17/2023)", - "MM/DD/YY (09/17/23)": "MM/DD/YY (09/17/23)", - "DD-MM-YYYY (17-09-2023)": "DD-MM-YYYY (17-09-2023)", - "DD/MM/YYYY (17/09/2023)": "DD/MM/YYYY (17/09/2023)", - "DD/MM/YY (17/09/23)": "DD/MM/YY (17/09/23)", - "X (1694949838)": "X (1694949838)", + "Pick how the result should look.": "Pick how the result should look.", + "Time zone the current date and time are reported in.": "Time zone the current date and time are reported in.", + "Usually a value from a previous step, in the format below.": "Usually a value from a previous step, in the format below.", + "Pick how your date is written.": "Pick how your date is written.", + "Time zone the date above is written in.": "Time zone the date above is written in.", + "Time zone to convert the result to.": "Time zone to convert the result to.", + "Each unit becomes its own output field. Month is 1 to 12.": "Each unit becomes its own output field. Month is 1 to 12.", + "Each unit becomes its own output field, as remainders not totals.": "Each unit becomes its own output field, as remainders not totals.", + "Use year, month, day, hour, minute, second with + or -.": "Use year, month, day, hour, minute, second with + or -.", + "Needed for the two options below and for daylight-saving.": "Needed for the two options below and for daylight-saving.", + "Pin the result to this time of day, HH:mm. Needs a Time Zone.": "Pin the result to this time of day, HH:mm. Needs a Time Zone.", + "Pin the result to the current time instead. Needs a Time Zone.": "Pin the result to the current time instead. Needs a Time Zone.", + "The next date on this weekday. Today counts if the time is ahead.": "The next date on this weekday. Today counts if the time is ahead.", + "Time zone used to work out the date and to report the result.": "Time zone used to work out the date and to report the result.", + "24-hour clock, HH:mm.": "24-hour clock, HH:mm.", + "Ignore the Time field and use the current time of day.": "Ignore the Time field and use the current time of day.", + "Month of the date to find.": "Month of the date to find.", + "Rolls into the next month if the month is shorter.": "Rolls into the next month if the month is shorter.", + "Sun Sep 17 2023 11:23:58": "Sun Sep 17 2023 11:23:58", + "Sun Sep 17 11:23:58 2023": "Sun Sep 17 11:23:58 2023", + "September 17 2023 11:23:58": "September 17 2023 11:23:58", + "September 17 2023": "September 17 2023", + "Sep 17 2023": "Sep 17 2023", + "2023-09-17T11:23:58": "2023-09-17T11:23:58", + "2023-09-17 11:23:58": "2023-09-17 11:23:58", + "2023-09-17": "2023-09-17", + "09-17-2023": "09-17-2023", + "09/17/2023": "09/17/2023", + "09/17/23": "09/17/23", + "17-09-2023": "17-09-2023", + "17/09/2023": "17/09/2023", + "17/09/23": "17/09/23", "(GMT-11:00) Pacific, Midway": "(GMT-11:00) Pacific, Midway", "(GMT-11:00) Pacific, Niue": "(GMT-11:00) Pacific, Niue", "(GMT-11:00) Pacific, Pago Pago": "(GMT-11:00) Pacific, Pago Pago", @@ -492,7 +489,7 @@ "Minute": "Minute", "Second": "Second", "Day of Week": "Day of Week", - "Month name": "Month name", + "Month Name": "Month Name", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", diff --git a/packages/pieces/core/date-helper/src/index.ts b/packages/pieces/core/date-helper/src/index.ts index 06edbb764d74..f25c08f91407 100644 --- a/packages/pieces/core/date-helper/src/index.ts +++ b/packages/pieces/core/date-helper/src/index.ts @@ -15,7 +15,7 @@ const description = `Manipulate, format, and extract time units for all your dat export const utilityDate = createPiece({ displayName: 'Date Helper', auth: PieceAuth.None(), - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.88.2', categories: [PieceCategory.CORE], logoUrl: 'https://cdn.activepieces.com/pieces/new-core/date-helper.svg', authors: [ diff --git a/packages/pieces/core/date-helper/src/lib/actions/add-subtract-date.ts b/packages/pieces/core/date-helper/src/lib/actions/add-subtract-date.ts index 369e58e500cc..560e08aa9174 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/add-subtract-date.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/add-subtract-date.ts @@ -6,7 +6,10 @@ import { optionalTimeFormats, parseDate, timeFormat, - timeFormatDescription, + inputFormatDescription, + outputFormatDescription, + dateInputDescription, + timeInputPlaceholder, timeParts, timeZoneOptions, } from '../common'; @@ -21,54 +24,81 @@ export const addSubtractDateAction = createAction({ displayName: 'Add/Subtract Time', description: 'Add or subtract time from a date', aiMetadata: { description: 'Shifts an input date by a signed year/month/day/hour/minute/second expression, and can pin the resulting clock time to a fixed 24h value or the current time. Use Date Difference to compare two dates, Format Date for a format or zone change only, and Next Day of Week or Next Day of Year for the next calendar occurrence. The Set Time To (HH:mm) and Use Current Time options apply only when a Time Zone is also selected; idempotent except when Use Current Time ties the result to the clock.', idempotent: true }, + propertyGroups: [ + { + key: 'input', + display: 'section', + label: 'Input', + icon: 'calendar', + props: ['inputDate', 'inputDateFormat'], + }, + { + key: 'shift', + display: 'section', + label: 'Shift', + icon: 'sliders', + props: ['expression'], + }, + { + key: 'output', + display: 'section', + label: 'Output', + icon: 'type', + props: ['outputFormat'], + }, + ], props: { inputDate: Property.ShortText({ - displayName: 'Input Date', - description: 'Enter the input date', + displayName: 'Date', + description: dateInputDescription, required: true, }), inputDateFormat: Property.StaticDropdown({ - displayName: 'From Time Format', - description: timeFormatDescription, + displayName: 'Format', + description: inputFormatDescription, options: { options: optionalTimeFormats, }, required: true, - defaultValue: getCorrectedFormat(timeFormat.format00), + defaultValue: timeFormat.format00, + }), + expression: Property.LongText({ + displayName: 'Expression', + placeholder: '+ 1 day - 2 hour', + description: 'Use year, month, day, hour, minute, second with + or -.', + required: true, }), outputFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, required: true, - defaultValue: getCorrectedFormat(timeFormat.format00), - }), - expression: Property.LongText({ - displayName: 'Expression', - description: `Provide an expression to add or subtract using the following units (year , month , day , hour , minute or second). - \nExamples:\n+ 2 second + 1 hour \n+ 1 year - 3 day - 2 month \n+ 5 minute`, - required: true, + defaultValue: timeFormat.format00, }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', - description: 'Optional: Set a timezone for the calculation to handle DST correctly', + description: 'Needed for the two options below and for daylight-saving.', options: { options: timeZoneOptions, }, required: false, + advanced: true, }), setTime: Property.ShortText({ - displayName: 'Set Time To (24h format)', - description: 'Optional: Set the result to a specific time (e.g., "10:00" or "14:30"). This allows scheduling at a specific time after adding/subtracting dates. Leave empty to keep the calculated time.', + displayName: 'Set Time', + description: 'Pin the result to this time of day, HH:mm. Needs a Time Zone.', + placeholder: timeInputPlaceholder, required: false, + advanced: true, }), useCurrentTime: Property.Checkbox({ displayName: 'Use Current Time', - description: 'If checked, the current time will be used instead of the time from "Set Time To" field.', + description: 'Pin the result to the current time instead. Needs a Time Zone.', required: false, defaultValue: false, + advanced: true, }), }, outputSchema: addSubtractDateActionOutputSchema, diff --git a/packages/pieces/core/date-helper/src/lib/actions/date-difference.ts b/packages/pieces/core/date-helper/src/lib/actions/date-difference.ts index 75aaf09bd9a4..971f3f076ebb 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/date-difference.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/date-difference.ts @@ -3,7 +3,8 @@ import { optionalTimeFormats, timeFormat, timeParts, - timeFormatDescription, + inputFormatDescription, + dateInputDescription, parseDate, getCorrectedFormat, apDayjs, @@ -16,15 +17,31 @@ export const dateDifferenceAction = createAction({ displayName: 'Date Difference', description: 'Get the difference between two dates', aiMetadata: { description: 'Measures the elapsed time between a start date and an end date, returning any combination of the requested units in one call. Use it to compare two known dates for age, SLA or gap checks; use Add/Subtract Time instead to shift a single date. Each date needs its own input pattern, and the units are decomposed duration components rather than totals - the day value is the remainder inside the month - and come back negative when the end date precedes the start; read-only and idempotent.', idempotent: true }, + propertyGroups: [ + { + key: 'start', + display: 'section', + label: 'Start', + icon: 'calendar', + props: ['startDate', 'startDateFormat'], + }, + { + key: 'end', + display: 'section', + label: 'End', + icon: 'calendar', + props: ['endDate', 'endDateFormat'], + }, + ], props: { startDate: Property.ShortText({ - displayName: 'Starting Date', - description: 'Enter the starting date', + displayName: 'Date', + description: dateInputDescription, required: true, }), startDateFormat: Property.StaticDropdown({ - displayName: 'Starting date format', - description: timeFormatDescription, + displayName: 'Format', + description: inputFormatDescription, options: { options: optionalTimeFormats, }, @@ -32,13 +49,13 @@ export const dateDifferenceAction = createAction({ defaultValue: timeFormat.format00, }), endDate: Property.ShortText({ - displayName: 'Ending Date', - description: 'Enter the ending date', + displayName: 'Date', + description: dateInputDescription, required: true, }), endDateFormat: Property.StaticDropdown({ - displayName: 'Ending date format', - description: timeFormatDescription, + displayName: 'Format', + description: inputFormatDescription, options: { options: optionalTimeFormats, }, @@ -46,8 +63,8 @@ export const dateDifferenceAction = createAction({ defaultValue: timeFormat.format00, }), unitDifference: Property.StaticMultiSelectDropdown({ - displayName: 'Unit', - description: 'Select the unit of difference between the two dates', + displayName: 'Units', + description: 'Each unit becomes its own output field, as remainders not totals.', options: { options: [ { label: 'Year', value: timeParts.year }, @@ -59,7 +76,7 @@ export const dateDifferenceAction = createAction({ ], }, required: true, - defaultValue: [timeParts.year], + defaultValue: [timeParts.day], }), }, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/actions/extract-date-parts.ts b/packages/pieces/core/date-helper/src/lib/actions/extract-date-parts.ts index b96c2d6a4d9f..98cfe2c5d703 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/extract-date-parts.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/extract-date-parts.ts @@ -3,7 +3,8 @@ import { optionalTimeFormats, timeFormat, timeParts, - timeFormatDescription, + inputFormatDescription, + dateInputDescription, parseDate, getCorrectedFormat, } from '../common'; @@ -13,18 +14,17 @@ export const extractDateParts = createAction({ name: 'extract_date_parts', classification: 'READ', displayName: 'Extract Date Units', - description: - 'Extract date units ( year , month , day , hour , minute , second , day of week , month name ) from a date', + description: 'Pull year, month, day, weekday and more out of a date', aiMetadata: { description: 'Parses one date string and returns the selected calendar components as separate values, any combination in a single call, with month as a 1-12 number and the weekday and month names spelled out. Use it when downstream logic needs the parts as data, for example branching on the weekday name; prefer Format Date when you only need the whole date rendered differently. Requires the date and its input pattern, and each requested unit must come from that supported set or the action throws; read-only and idempotent.', idempotent: true }, props: { inputDate: Property.ShortText({ - displayName: 'Input Date', - description: 'Enter the input date', + displayName: 'Date', + description: dateInputDescription, required: true, }), inputFormat: Property.StaticDropdown({ - displayName: 'From Time Format', - description: timeFormatDescription, + displayName: 'Date Format', + description: inputFormatDescription, options: { options: optionalTimeFormats, }, @@ -32,8 +32,8 @@ export const extractDateParts = createAction({ defaultValue: timeFormat.format00, }), unitExtract: Property.StaticMultiSelectDropdown({ - displayName: 'Unit to Extract', - description: 'Select the unit to extract from the date', + displayName: 'Units', + description: 'Each unit becomes its own output field. Month is 1 to 12.', options: { options: [ { label: 'Year', value: timeParts.year }, @@ -43,11 +43,11 @@ export const extractDateParts = createAction({ { label: 'Minute', value: timeParts.minute }, { label: 'Second', value: timeParts.second }, { label: 'Day of Week', value: timeParts.dayOfWeek }, - { label: 'Month name', value: timeParts.monthName }, + { label: 'Month Name', value: timeParts.monthName }, ], }, required: true, - defaultValue: [timeParts.year], + defaultValue: [timeParts.year, timeParts.month, timeParts.day], }), }, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/actions/first-day-of-prior-month.ts b/packages/pieces/core/date-helper/src/lib/actions/first-day-of-prior-month.ts index 6a7250e456fd..44cb6fb81a8f 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/first-day-of-prior-month.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/first-day-of-prior-month.ts @@ -2,7 +2,10 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { optionalTimeFormats, timeFormat, - timeFormatDescription, + outputFormatDescription, + timeInputDescription, + timeInputPlaceholder, + useCurrentTimeDescription, timeZoneOptions, getCorrectedFormat, apDayjs, @@ -19,23 +22,9 @@ export const firstDayOfPreviousMonthAction = createAction({ description: 'Get the date and time of the first day of the previous month', aiMetadata: { description: 'Returns the first calendar day of the month before the current month, stamped with midnight, a supplied 24h time, or the current time. Use it as the opening bound of a last-month reporting window, paired with Last Day of Previous Month; use Add/Subtract Time for an arbitrary offset from a specific date. Time zone and output format are required and the time must be HH:mm; not idempotent - the window is derived from the current clock and changes once the month rolls over.', idempotent: false }, props: { - time: Property.ShortText({ - displayName: '24h Time', - description: - 'The time that you would like to get the date and time of. This must be in 24h format.', - required: false, - defaultValue: '00:00', - }), - currentTime: Property.Checkbox({ - displayName: 'Use Current Time', - description: - 'If checked, the current time will be used instead of the time specified above.', - required: false, - defaultValue: false, - }), timeFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Output Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -44,12 +33,28 @@ export const firstDayOfPreviousMonthAction = createAction({ }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', + description: + 'Time zone used to work out the date and to report the result.', options: { options: timeZoneOptions, }, required: true, defaultValue: 'UTC', }), + time: Property.ShortText({ + displayName: 'Time', + description: timeInputDescription, + placeholder: timeInputPlaceholder, + required: false, + defaultValue: '00:00', + }), + currentTime: Property.Checkbox({ + displayName: 'Use Current Time', + description: useCurrentTimeDescription, + required: false, + defaultValue: false, + advanced: true, + }), }, outputSchema: firstDayOfPreviousMonthActionOutputSchema, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/actions/format-date.ts b/packages/pieces/core/date-helper/src/lib/actions/format-date.ts index bc03613c4138..b5f72db9ec35 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/format-date.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/format-date.ts @@ -3,7 +3,9 @@ import { optionalTimeFormats, parseDate, timeFormat, - timeFormatDescription, + inputFormatDescription, + outputFormatDescription, + dateInputDescription, timeZoneOptions, getCorrectedFormat, } from '../common'; @@ -16,15 +18,31 @@ export const formatDateAction = createAction({ displayName: 'Format Date', description: 'Converts a date from one format to another', aiMetadata: { description: 'Converts a date string from one pattern and time zone into another in a single step. Pick this for a pure representation change; use Extract Date Units to pull components as data, Add/Subtract Time to move the date, and Date Difference to compare two dates. Requires the input date plus source and target format and zone; the source pattern is only a parsing hint (parsing falls back to lenient then native parsing) and the action throws if the value cannot be parsed at all, but is otherwise deterministic and idempotent.', idempotent: true }, + propertyGroups: [ + { + key: 'input', + display: 'section', + label: 'Input', + icon: 'calendar', + props: ['inputDate', 'inputFormat', 'inputTimeZone'], + }, + { + key: 'output', + display: 'section', + label: 'Output', + icon: 'type', + props: ['outputFormat', 'outputTimeZone'], + }, + ], props: { inputDate: Property.ShortText({ - displayName: 'Input Date', - description: 'Enter the input date', + displayName: 'Date', + description: dateInputDescription, required: true, }), inputFormat: Property.StaticDropdown({ - displayName: 'From Time Format', - description: timeFormatDescription, + displayName: 'Format', + description: inputFormatDescription, options: { options: optionalTimeFormats, }, @@ -32,7 +50,8 @@ export const formatDateAction = createAction({ defaultValue: timeFormat.format00, }), inputTimeZone: Property.StaticDropdown({ - displayName: 'From Time Zone', + displayName: 'Time Zone', + description: 'Time zone the date above is written in.', options: { options: timeZoneOptions, }, @@ -40,8 +59,8 @@ export const formatDateAction = createAction({ defaultValue: 'UTC', }), outputFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -49,7 +68,8 @@ export const formatDateAction = createAction({ defaultValue: timeFormat.format00, }), outputTimeZone: Property.StaticDropdown({ - displayName: 'To Time Zone', + displayName: 'Time Zone', + description: 'Time zone to convert the result to.', options: { options: timeZoneOptions, }, diff --git a/packages/pieces/core/date-helper/src/lib/actions/get-current-date.ts b/packages/pieces/core/date-helper/src/lib/actions/get-current-date.ts index 509f9205d37f..f214a44f7ea6 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/get-current-date.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/get-current-date.ts @@ -2,7 +2,7 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { optionalTimeFormats, timeFormat, - timeFormatDescription, + outputFormatDescription, timeZoneOptions, getCorrectedFormat, apDayjs @@ -18,8 +18,8 @@ export const getCurrentDate = createAction({ aiMetadata: { description: 'Reads the clock and returns the current date and time for a chosen IANA time zone, rendered with one of 15 preset patterns (including unix seconds via X). Use it to stamp a run with the current instant or to produce the base date that Format Date, Add/Subtract Time or Date Difference consume; it accepts no date input, so prefer Format Date when you already have a date string. Output format and time zone are both required; not idempotent, since every call reads the clock and returns a different value.', idempotent: false }, props: { timeFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Output Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -28,6 +28,7 @@ export const getCurrentDate = createAction({ }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', + description: 'Time zone the current date and time are reported in.', options: { options: timeZoneOptions, }, diff --git a/packages/pieces/core/date-helper/src/lib/actions/last-day-of-prior-month.ts b/packages/pieces/core/date-helper/src/lib/actions/last-day-of-prior-month.ts index 4539636292b0..d48d0f317afe 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/last-day-of-prior-month.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/last-day-of-prior-month.ts @@ -2,7 +2,10 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { optionalTimeFormats, timeFormat, - timeFormatDescription, + outputFormatDescription, + timeInputDescription, + timeInputPlaceholder, + useCurrentTimeDescription, timeZoneOptions, getCorrectedFormat, apDayjs, @@ -19,23 +22,9 @@ export const lastDayOfPreviousMonthAction = createAction({ description: 'Get the date and time of the last day of the previous month', aiMetadata: { description: 'Returns the last calendar day of the month before the current month, stamped with midnight, a supplied 24h time, or the current time - the time is overwritten rather than set to end of day, so 23:59 must be asked for explicitly. Use it as the closing bound of a last-month reporting window whose start comes from First Day of Previous Month. Time zone and output format are required and the time must be HH:mm; not idempotent - derived from the current clock, changing once the month rolls over.', idempotent: false }, props: { - time: Property.ShortText({ - displayName: '24h Time', - description: - 'The time that you would like to get the date and time of. This must be in 24h format.', - required: false, - defaultValue: '00:00', - }), - currentTime: Property.Checkbox({ - displayName: 'Use Current Time', - description: - 'If checked, the current time will be used instead of the time specified above.', - required: false, - defaultValue: false, - }), timeFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Output Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -44,12 +33,28 @@ export const lastDayOfPreviousMonthAction = createAction({ }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', + description: + 'Time zone used to work out the date and to report the result.', options: { options: timeZoneOptions, }, required: true, defaultValue: 'UTC', }), + time: Property.ShortText({ + displayName: 'Time', + description: timeInputDescription, + placeholder: timeInputPlaceholder, + required: false, + defaultValue: '00:00', + }), + currentTime: Property.Checkbox({ + displayName: 'Use Current Time', + description: useCurrentTimeDescription, + required: false, + defaultValue: false, + advanced: true, + }), }, outputSchema: lastDayOfPreviousMonthActionOutputSchema, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/actions/next-day-of-week.ts b/packages/pieces/core/date-helper/src/lib/actions/next-day-of-week.ts index c36f4838b237..cf7186d4e9d5 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/next-day-of-week.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/next-day-of-week.ts @@ -5,7 +5,10 @@ import { import { optionalTimeFormats, timeFormat, - timeFormatDescription, + outputFormatDescription, + timeInputDescription, + timeInputPlaceholder, + useCurrentTimeDescription, timeZoneOptions, getCorrectedFormat, apDayjs, @@ -25,7 +28,7 @@ export const nextDayofWeek = createAction({ weekday: Property.StaticDropdown({ displayName: 'Weekday', description: - 'The weekday that you would like to get the date and time of.', + 'The next date on this weekday. Today counts if the time is ahead.', options: { options: [ { label: 'Sunday', value: 0 }, @@ -38,24 +41,11 @@ export const nextDayofWeek = createAction({ ], }, required: true, - }), - time: Property.ShortText({ - displayName: '24h Time', - description: - 'The time that you would like to get the date and time of. This must be in 24h format.', - required: false, - defaultValue: '00:00', - }), - currentTime: Property.Checkbox({ - displayName: 'Use Current Time', - description: - 'If checked, the current time will be used instead of the time specified above.', - required: false, - defaultValue: false, + defaultValue: 1, }), timeFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Output Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -64,12 +54,28 @@ export const nextDayofWeek = createAction({ }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', + description: + 'Time zone used to work out the date and to report the result.', options: { options: timeZoneOptions, }, required: true, defaultValue: 'UTC', }), + time: Property.ShortText({ + displayName: 'Time', + description: timeInputDescription, + placeholder: timeInputPlaceholder, + required: false, + defaultValue: '00:00', + }), + currentTime: Property.Checkbox({ + displayName: 'Use Current Time', + description: useCurrentTimeDescription, + required: false, + defaultValue: false, + advanced: true, + }), }, outputSchema: nextDayOfWeekActionOutputSchema, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/actions/next-day-of-year.ts b/packages/pieces/core/date-helper/src/lib/actions/next-day-of-year.ts index 2fe4d8e3d8f9..6283dc9c7c97 100644 --- a/packages/pieces/core/date-helper/src/lib/actions/next-day-of-year.ts +++ b/packages/pieces/core/date-helper/src/lib/actions/next-day-of-year.ts @@ -5,7 +5,10 @@ import { import { optionalTimeFormats, timeFormat, - timeFormatDescription, + outputFormatDescription, + timeInputDescription, + timeInputPlaceholder, + useCurrentTimeDescription, timeZoneOptions, getCorrectedFormat, apDayjs, @@ -24,7 +27,7 @@ export const nextDayofYear = createAction({ props: { month: Property.StaticDropdown({ displayName: 'Month', - description: 'The month that you would like to get the date and time of.', + description: 'Month of the date to find.', options: { options: [ { label: 'January', value: 1 }, @@ -46,27 +49,17 @@ export const nextDayofYear = createAction({ day: Property.Number({ displayName: 'Day of Month', description: - 'The day of the month that you would like to get the date and time of.', + 'Rolls into the next month if the month is shorter.', required: true, defaultValue: 1, - }), - time: Property.ShortText({ - displayName: '24h Time', - description: - 'The time that you would like to get the date and time of. This must be in 24h format.', - required: false, - defaultValue: '00:00', - }), - currentTime: Property.Checkbox({ - displayName: 'Use Current Time', - description: - 'If checked, the current time will be used instead of the time specified above.', - required: false, - defaultValue: false, + display: 'stepper', + min: 1, + max: 31, + step: 1, }), timeFormat: Property.StaticDropdown({ - displayName: 'To Time Format', - description: timeFormatDescription, + displayName: 'Output Format', + description: outputFormatDescription, options: { options: optionalTimeFormats, }, @@ -75,12 +68,28 @@ export const nextDayofYear = createAction({ }), timeZone: Property.StaticDropdown({ displayName: 'Time Zone', + description: + 'Time zone used to work out the date and to report the result.', options: { options: timeZoneOptions, }, required: true, defaultValue: 'UTC', }), + time: Property.ShortText({ + displayName: 'Time', + description: timeInputDescription, + placeholder: timeInputPlaceholder, + required: false, + defaultValue: '00:00', + }), + currentTime: Property.Checkbox({ + displayName: 'Use Current Time', + description: useCurrentTimeDescription, + required: false, + defaultValue: false, + advanced: true, + }), }, outputSchema: nextDayOfYearActionOutputSchema, async run(context) { diff --git a/packages/pieces/core/date-helper/src/lib/common/index.ts b/packages/pieces/core/date-helper/src/lib/common/index.ts index 8226792f4dc3..ae6cdf4fbc5c 100644 --- a/packages/pieces/core/date-helper/src/lib/common/index.ts +++ b/packages/pieces/core/date-helper/src/lib/common/index.ts @@ -46,22 +46,22 @@ export enum timeFormat { format14 = 'X', } -export enum timeFormatLabel { - format00 = 'DDD MMM DD YYYY HH:mm:ss (Sun Sep 17 2023 11:23:58)', - format01 = 'DDD MMM DD HH:mm:ss YYYY (Sun Sep 17 11:23:58 2023)', - format02 = 'MMMM DD YYYY HH:mm:ss (September 17 2023 11:23:58)', - format03 = 'MMMM DD YYYY (September 17 2023)', - format04 = 'MMM DD YYYY (Sep 17 2023)', - format05 = 'YYYY-MM-DDTHH:mm:ss (2023-09-17T11:23:58) ', - format06 = 'YYYY-MM-DD HH:mm:ss (2023-09-17 11:23:58)', - format07 = 'YYYY-MM-DD (2023-09-17)', - format08 = 'MM-DD-YYYY (09-17-2023)', - format09 = 'MM/DD/YYYY (09/17/2023)', - format10 = 'MM/DD/YY (09/17/23)', - format11 = 'DD-MM-YYYY (17-09-2023)', - format12 = 'DD/MM/YYYY (17/09/2023)', - format13 = 'DD/MM/YY (17/09/23)', - format14 = 'X (1694949838)', +export enum timeFormatExample { + format00 = 'Sun Sep 17 2023 11:23:58', + format01 = 'Sun Sep 17 11:23:58 2023', + format02 = 'September 17 2023 11:23:58', + format03 = 'September 17 2023', + format04 = 'Sep 17 2023', + format05 = '2023-09-17T11:23:58', + format06 = '2023-09-17 11:23:58', + format07 = '2023-09-17', + format08 = '09-17-2023', + format09 = '09/17/2023', + format10 = '09/17/23', + format11 = '17-09-2023', + format12 = '17/09/2023', + format13 = '17/09/23', + format14 = '1694949838', } @@ -102,36 +102,93 @@ export function parseDate(date: string, format: string): dayjs.Dayjs { } return djs; } -export const timeFormatDescription = `Here's what each part of the format (e.g., YYYY) represents: -\nYYYY : Year (4 digits) - Example: 2023 -\nYY : Year (2 digits) - Example: 23 -\nMMMM : Month (full name) - Example: September -\nMMM : Month (short name) - Example: Sep -\nMM : Month (2 digits) - Example: 09 -\nDDDD : Day (full name) - Example: Sunday -\nDDD : Day (short name) - Example: Sun -\nDD : Day (2 digits) - Example: 17 -\nHH : Hour (2 digits) - Example: 11 -\nmm : Minute (2 digits) - Example: 23 -\nss : Second (2 digits) - Example: 58 -\nX : Time in Unix format - Example: 1694949838`; +export const inputFormatDescription = + 'Pick how your date is written.'; +export const outputFormatDescription = + 'Pick how the result should look.'; +export const dateInputDescription = + 'Usually a value from a previous step, in the format below.'; +export const timeInputDescription = '24-hour clock, HH:mm.'; +export const timeInputPlaceholder = '14:30'; +export const useCurrentTimeDescription = + 'Ignore the Time field and use the current time of day.'; export const optionalTimeFormats = [ - { label: timeFormatLabel.format00, value: timeFormat.format00 }, - { label: timeFormatLabel.format01, value: timeFormat.format01 }, - { label: timeFormatLabel.format02, value: timeFormat.format02 }, - { label: timeFormatLabel.format03, value: timeFormat.format03 }, - { label: timeFormatLabel.format04, value: timeFormat.format04 }, - { label: timeFormatLabel.format05, value: timeFormat.format05 }, - { label: timeFormatLabel.format06, value: timeFormat.format06 }, - { label: timeFormatLabel.format07, value: timeFormat.format07 }, - { label: timeFormatLabel.format08, value: timeFormat.format08 }, - { label: timeFormatLabel.format09, value: timeFormat.format09 }, - { label: timeFormatLabel.format10, value: timeFormat.format10 }, - { label: timeFormatLabel.format11, value: timeFormat.format11 }, - { label: timeFormatLabel.format12, value: timeFormat.format12 }, - { label: timeFormatLabel.format13, value: timeFormat.format13 }, - { label: timeFormatLabel.format14, value: timeFormat.format14 }, + { + label: timeFormatExample.format00, + value: timeFormat.format00, + description: timeFormat.format00, + }, + { + label: timeFormatExample.format01, + value: timeFormat.format01, + description: timeFormat.format01, + }, + { + label: timeFormatExample.format02, + value: timeFormat.format02, + description: timeFormat.format02, + }, + { + label: timeFormatExample.format03, + value: timeFormat.format03, + description: timeFormat.format03, + }, + { + label: timeFormatExample.format04, + value: timeFormat.format04, + description: timeFormat.format04, + }, + { + label: timeFormatExample.format05, + value: timeFormat.format05, + description: timeFormat.format05, + }, + { + label: timeFormatExample.format06, + value: timeFormat.format06, + description: timeFormat.format06, + }, + { + label: timeFormatExample.format07, + value: timeFormat.format07, + description: timeFormat.format07, + }, + { + label: timeFormatExample.format08, + value: timeFormat.format08, + description: timeFormat.format08, + }, + { + label: timeFormatExample.format09, + value: timeFormat.format09, + description: timeFormat.format09, + }, + { + label: timeFormatExample.format10, + value: timeFormat.format10, + description: timeFormat.format10, + }, + { + label: timeFormatExample.format11, + value: timeFormat.format11, + description: timeFormat.format11, + }, + { + label: timeFormatExample.format12, + value: timeFormat.format12, + description: timeFormat.format12, + }, + { + label: timeFormatExample.format13, + value: timeFormat.format13, + description: timeFormat.format13, + }, + { + label: timeFormatExample.format14, + value: timeFormat.format14, + description: 'X (Unix timestamp)', + }, ]; export const timeZoneOptions = [