diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 167ec72035fa..5eabcb51f81e 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -69,6 +69,7 @@ enqueued. `AP_MODEL_TIERS_URL` overrides the URL. The file is published from the decision 000041. The console publishes both lists and both fast pointers on every Publish, and on its first load after a file without chat keys it seeds the chat list from the live flow list. ### Gotchas +- **The managed key only runs models the tiers or the chat allow-list permit, enforced in `aiUtils.createModel`.** The managed provider is OpenRouter underneath, so before this guard a crafted step naming any OpenRouter model ran on our key; the picker hid it but nothing denied it. `assertManagedModelAllowed` (`server/utils/src/ai-utils.ts`) checks `managedChatModelIds()` plus both surfaces' published tier `modelId`s and throws a `VALIDATION` error. Image models are checked too, in `createModel` (`imageGeneration: true`) and `createModelForImages`: only `ACTIVEPIECES_IMAGE_TIERS` model ids, plus the id the chat path passes as `adminChosenImageModelId` (the server-chosen Capabilities model). The worker now calls `modelTierCatalog.warmUp()` at start so a cold worker does not judge a newly published tier against the bundled list. - **A catalog change takes up to ~2 days to reach a dropdown, through three caches in series.** CDN edge (`--cache-control max-age=3600`, 1h) → the server's in-memory catalog (`CATALOG_TTL_MS`, 24h) → `modelsCache` (flushed by the nightly `cron.schedule('0 0 * * *')`). So "I republished but the UI still shows the old price" is expected, not a bug; restarting the API short-circuits the two in-process layers. The edge TTL is deliberately 1h and not the 604800 that `publish-embed-sdk.yml` uses — that path is version-stamped, ours is a stable key rewritten in place, so a week-long edge cache would pin stale prices for a week. - **`Sync AI Model Catalog` is the only workflow that proves the CDN S3 credentials still work — a green `Publish Embed SDK` proves nothing.** Both read the same `CDN_S3_ACCESS_KEY_ID` / `CDN_S3_SECRET_ACCESS_KEY` / `CDN_S3_BUCKET` / `CDN_S3_ENDPOINT` secrets, but `publish-embed-sdk.yml` guards its upload job on `needs.check.outputs.publish == 'true'`, which is false whenever the version in `packages/ee/embed-sdk/package.json` is already on the CDN. Its daily cron then finishes in about nine seconds with the upload skipped and the run still marked success, so the key can rot for weeks behind a green tick — that is how the model-catalog cron came to fail with `InvalidAccessKeyId` on three consecutive Mondays while the embed SDK looked healthy. To date a dead key, find the last embed-SDK run whose `publish` job actually ran (`gh run list --workflow=publish-embed-sdk.yml`, look for a duration in minutes rather than seconds) and compare it against the first model-catalog failure. To test a rotated key, dispatch `sync-model-catalog.yml` — it uploads unconditionally. - **Publishing is an overwrite of one S3 key, and it only ever happens on the Monday cron or a manual `workflow_dispatch`.** Never on merge, deploy or release. There is no versioning and no history: last write wins and the previous contents are gone, which is why the object carries `generatedAt` — `curl -s https://cdn.activepieces.com/ai/model-catalog.json | jq .generatedAt` is the only way to tell how fresh what you are serving is. Editing the generator changes nothing in production until someone dispatches the workflow. diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md index ff6c555aee18..4c1bb026ee14 100644 --- a/brain/knowledge/flows-execution/chat.md +++ b/brain/knowledge/flows-execution/chat.md @@ -31,11 +31,12 @@ A turn is kept alive / reclaimed by three separate mechanisms in `execute-agent- - **Stream idle watchdog** (`STREAM_IDLE_TIMEOUT_MS` 90s, in `streamChunksToClient`): aborts the turn if the drain-stream reader is silent 90s. It must be SUSPENDED while legitimate silent work is in flight — pending tool calls AND in-flight reasoning (`reasoning-start`→`reasoning-end`). **Reasoning-awareness was missing and caused the bug where long "thinking" on the Expert tier randomly aborted a healthy turn** (a >90s gap between reasoning deltas looked like a wedge). Backstop for a genuine mid-reasoning wedge is `MAX_TURN_WALL_CLOCK_MS` (20 min). ### Gotchas +- **Chat has no fixed step cap.** A chat turn is bounded by credits (checked after every step), the runaway-token ceiling (`RUNAWAY_TURN_CONTEXT_MULTIPLE` × the context window), the identical-failure guard and the 2-hour `MAX_AGENT_TURN_WALL_CLOCK_MS`, so it finishes long jobs. A saved agent's author-set `maxSteps` is still honored on its last step: an agent with structured output is forced to call `TASK_COMPLETION_TOOL_NAME` so the flow gets a result, and any other agent runs with `toolChoice: 'none'` plus a wrap-up note, so it replies with what's done and what's left. Never a silent stop or an error. - **The streaming lock is per run, not per conversation.** A re-send can claim `activeRunId` before the first job reaches `getAgentConfig`. `agentHelpers.acquireStreamingLock` only lets the owning run set STREAMING; an older run gets `AGENT_RUN_SUPERSEDED` and the worker exits quietly, with no ERROR save and no events. A lock that ignored `runId` let the old run stream unowned while the new one was rejected as busy and marked ERROR, which is how users saw nothing after tapping a starter card twice. - **A failed turn used to leave status ERROR with an empty reply.** The error text only travelled as a websocket `ERROR` event, so a reload, another tab, or a user who was away saw a dead conversation (and often no user message either when the job died inside `getAgentConfig`, before it persists the turn). The `execute-agent-run.ts` catch now sends `failure: { message, userMessage? }` on the empty `saveAgentMessages` call and `conversation-rpc.ts` appends it to `uiMessages` as an assistant text reply; `use-chat.ts` shows the Retry banner when a loaded conversation is ERROR. Stale STREAMING to IDLE recovery, a worker that never picks the job up, and `assertAgentMessageRateLimitNotExceeded` still leave no reply of their own. - **A built flow ends with a "Turn it on?" card, never an auto-publish**: an audit found 40% of built-but-unpublished flows ended on "open it to review", some claiming LIVE. Chat never publishes on its own; only a yes to the card calls `ap_lock_and_publish` (`build_flow.md` "Turn it on?"). Pinned by the `build-then-ask-turn-it-on` fixture (`askedToTurnItOn`, `noLiveClaimWithoutPublish`). The live-claim check is a regex plus "was `ap_lock_and_publish` called", so it cannot see whether publish succeeded. - **Server-managed connections**: the LLM never sees connection externalIds; `ap_execute_action` auto-fills them from a Redis store. -- **Prompt-injection taint**: a per-turn `taintState` flips to `tainted` once the turn reads outside or user data (MCP tools, web/search/scrape, `ap_explore_data`, `ap_execute_action`, `ap_run_code`, `ap_list_across_projects`, configured tools). It forces the action-preview gate on non-read-only actions, blocks saved-agent edits, and makes `ap_remember` ask before saving (memory reaches every future conversation). It carries one message: each saved reply stores `tainted` (this reply's own reads, via `createTaintState`), and the next message starts from it. Deletes are confirmed always, not by taint. +- **Prompt-injection taint**: a per-turn `taintState` flips to `tainted` once the turn reads outside or user data (MCP tools, web/search/scrape, `ap_explore_data`, `ap_execute_action`, `ap_run_code`, `ap_list_across_projects`, configured tools). It forces the action-preview gate on non-read-only actions, makes saved-agent edits (`ap_update_agent`, `ap_add_agent_tool`, `ap_remove_agent_tool`, `ap_create_agent`) ask for approval on the action card instead of refusing, and makes `ap_remember` ask before saving (memory reaches every future conversation). It carries one message: each saved reply stores `tainted` (this reply's own reads, via `createTaintState`), and the next message starts from it. Deletes are confirmed always, not by taint. - **Write-check gate**: before a live `ap_test_flow`, `__flow_write_check` RPC flags write/destructive PIECE steps; read-only flows run ungated; gate fails open on RPC error. - **Cloud rollout cap**: opens to non-embed users without `chatEnabled` until 200 distinct users have sent a message (`CLOUD_CHAT_ROLLOUT_CAP`); grandfathered after close. Embedded sessions never see chat. - **Flow correctness is 100% prompt/guide-driven — nothing in code enforces it.** The "#1 silent bug" ("Class A"): the agent frames a *recurring* automation as a *one-time task* and omits any anti-reprocessing step, so run N+1 redoes run N's work (re-pays, re-sends). It's a design-time reasoning gap, not a testing gap — `ap_test_flow` runs ONCE, so a single test looks perfect; the bug only shows on the 2nd run. Fix lives in the prompt (`chat-system-prompt.md` `` + `build_flow.md` "Recurring flows must not reprocess") + capability eval fixtures with a `recurring_avoids_reprocessing` judge dimension. The platform already has every primitive (Tables New-Record webhook, polling `DedupeStrategy`, `_dedupe_key`, Store, update/delete-record); the agent just wasn't reaching for them. Watch the `build_flow.md` "don't over-build" bias — it once actively discouraged the fix. diff --git a/bun.lock b/bun.lock index 5e6d3e85e638..01f160364f7a 100644 --- a/bun.lock +++ b/bun.lock @@ -120,7 +120,7 @@ }, "packages/core/execution": { "name": "@activepieces/core-execution", - "version": "0.23.4", + "version": "0.23.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -188,7 +188,7 @@ }, "packages/core/utils": { "name": "@activepieces/core-utils", - "version": "0.8.2", + "version": "0.8.3", "dependencies": { "deepmerge-ts": "8.0.2", "ipaddr.js": "2.3.0", @@ -226,6 +226,18 @@ "vitest": "3.2.6", }, }, + "packages/pieces/community/0codekit": { + "name": "@activepieces/piece-0codekit", + "version": "0.0.1", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6", + }, + }, "packages/pieces/community/activecampaign": { "name": "@activepieces/piece-activecampaign", "version": "0.1.8", @@ -2224,9 +2236,21 @@ "tslib": "2.6.2", }, }, + "packages/pieces/community/convertapi": { + "name": "@activepieces/piece-convertapi", + "version": "0.0.1", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6", + }, + }, "packages/pieces/community/convertkit": { "name": "@activepieces/piece-convertkit", - "version": "0.3.9", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2235,6 +2259,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/copper": { @@ -2348,7 +2373,7 @@ }, "packages/pieces/community/customer-io": { "name": "@activepieces/piece-customer-io", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2358,6 +2383,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/customgpt": { @@ -2900,13 +2926,14 @@ }, "packages/pieces/community/elevenlabs": { "name": "@activepieces/piece-elevenlabs", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@elevenlabs/elevenlabs-js": "2.4.1", + "form-data": "4.0.6", }, "devDependencies": { "tslib": "2.6.2", @@ -2992,7 +3019,7 @@ }, "packages/pieces/community/exa": { "name": "@activepieces/piece-exa", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3001,6 +3028,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/extracta-ai": { @@ -3127,7 +3155,7 @@ }, "packages/pieces/community/figma": { "name": "@activepieces/piece-figma", - "version": "0.6.2", + "version": "0.6.3", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3608,17 +3636,19 @@ }, "packages/pieces/community/ghostcms": { "name": "@activepieces/piece-ghostcms", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", + "form-data": "4.0.6", "jsonwebtoken": "9.0.1", }, "devDependencies": { "@types/jsonwebtoken": "9.0.10", "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/giftbit": { @@ -4715,6 +4745,18 @@ "tslib": "2.6.2", }, }, + "packages/pieces/community/jsm-operations": { + "name": "@activepieces/piece-jsm-operations", + "version": "0.0.1", + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*", + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6", + }, + }, "packages/pieces/community/json": { "name": "@activepieces/piece-json", "version": "0.1.12", @@ -6377,7 +6419,7 @@ }, "packages/pieces/community/ntfy": { "name": "@activepieces/piece-ntfy", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6386,6 +6428,7 @@ }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/nuelink": { @@ -10184,16 +10227,16 @@ }, "packages/pieces/community/zoho-crm": { "name": "@activepieces/piece-zoho-crm", - "version": "0.3.1", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", - "dayjs": "1.11.9", }, "devDependencies": { "tslib": "2.6.2", + "vitest": "3.2.6", }, }, "packages/pieces/community/zoho-desk": { @@ -10701,7 +10744,7 @@ }, "packages/pieces/core/xml": { "name": "@activepieces/piece-xml", - "version": "0.1.20", + "version": "0.2.1", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -11181,6 +11224,8 @@ "@activepieces/engine": ["@activepieces/engine@workspace:packages/server/engine"], + "@activepieces/piece-0codekit": ["@activepieces/piece-0codekit@workspace:packages/pieces/community/0codekit"], + "@activepieces/piece-activecampaign": ["@activepieces/piece-activecampaign@workspace:packages/pieces/community/activecampaign"], "@activepieces/piece-activepieces": ["@activepieces/piece-activepieces@workspace:packages/pieces/community/activepieces"], @@ -11475,6 +11520,8 @@ "@activepieces/piece-contiguity": ["@activepieces/piece-contiguity@workspace:packages/pieces/community/contiguity"], + "@activepieces/piece-convertapi": ["@activepieces/piece-convertapi@workspace:packages/pieces/community/convertapi"], + "@activepieces/piece-convertkit": ["@activepieces/piece-convertkit@workspace:packages/pieces/community/convertkit"], "@activepieces/piece-copper": ["@activepieces/piece-copper@workspace:packages/pieces/community/copper"], @@ -11855,6 +11902,8 @@ "@activepieces/piece-jotform": ["@activepieces/piece-jotform@workspace:packages/pieces/community/jotform"], + "@activepieces/piece-jsm-operations": ["@activepieces/piece-jsm-operations@workspace:packages/pieces/community/jsm-operations"], + "@activepieces/piece-json": ["@activepieces/piece-json@workspace:packages/pieces/community/json"], "@activepieces/piece-jungle-grid": ["@activepieces/piece-jungle-grid@workspace:packages/pieces/community/jungle-grid"], diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index c700014f177b..4f9687a9cd0f 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -8,6 +8,16 @@ icon: "hammer" ### What has changed? +#### Zoho CRM Read File and Custom API Call only send the token to the connection's Zoho CRM hosts + +Read File used to send the connection's Zoho access token to whatever URL it was given, including hosts outside Zoho. From Zoho CRM 0.4.0 it only fetches `https` URLs on the connection's own Zoho API host under `/crm/` (for example `https://www.zohoapis.com/crm/bulk/v8/read//result`), or on that data centre's download hosts (for example `https://download-accl.zoho.com/v2/crm/...` for a data backup). Custom Zoho function URLs (`/crm/.../functions/...`) and URLs in another data centre are refused. Any other URL fails before a request is made. Redirects are followed only to those same hosts; a redirect anywhere else stops the download before that host is requested. The file is streamed, so the deployment's file size limit (`AP_MAX_FILE_SIZE_MB`) applies. + +Custom API Call keeps working with paths such as `/Leads` and with full URLs on the connection's own API host (for example `https://www.zohoapis.com/crm/v8/...`). A full URL on any other host, including other Zoho hosts, now fails before a request is made instead of receiving the token. + +#### What you need to do + +Nothing if your Read File steps point at Zoho CRM backup, bulk-read or API file URLs of the connected account, and your Custom API Call steps use a path or a URL on the connection's API host. If a step calls any other URL, replace it with the HTTP piece's Send HTTP Request action. Steps keep running on the version they were built with until you upgrade them. + #### Instagram for Business no longer reports success when a reel fails to publish Publishing a reel uploads the video to Instagram and then waits for Instagram to finish processing it before the post goes live. The piece used to give up after about a hundred seconds and return the boolean `false`, which the flow recorded as a **successful** step. Anything downstream carried on with `false` where a media id was expected, and nothing in the run said the post had not been published. diff --git a/packages/pieces/community/0codekit/.eslintrc.json b/packages/pieces/community/0codekit/.eslintrc.json new file mode 100644 index 000000000000..9ee1a0770e89 --- /dev/null +++ b/packages/pieces/community/0codekit/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "lodash", + "lodash/*", + "@activepieces/core-*", + "@activepieces/server*", + "@activepieces/engine", + "@activepieces/shared" + ] + } + ] + } + }, + { "files": ["*.js", "*.jsx"], "rules": {} } + ] +} diff --git a/packages/pieces/community/0codekit/package.json b/packages/pieces/community/0codekit/package.json new file mode 100644 index 000000000000..da48716a7b2b --- /dev/null +++ b/packages/pieces/community/0codekit/package.json @@ -0,0 +1,20 @@ +{ + "name": "@activepieces/piece-0codekit", + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "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'", + "test": "vitest run" + }, + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*" + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6" + } +} diff --git a/packages/pieces/community/0codekit/src/i18n/translation.json b/packages/pieces/community/0codekit/src/i18n/translation.json new file mode 100644 index 000000000000..2cd8b2b6db8a --- /dev/null +++ b/packages/pieces/community/0codekit/src/i18n/translation.json @@ -0,0 +1,675 @@ +{ + "Ready-made utilities for automations: dates and calendar weeks, conversions and validation.": "Ready-made utilities for automations: dates and calendar weeks, conversions and validation.", + "To get your API key:\n1. Log in to your [0CodeKit](https://0codekit.com) account.\n2. Open the **Account** tab. Your API key is shown there.\n3. Copy the key and paste it here.": "To get your API key:\n1. Log in to your [0CodeKit](https://0codekit.com) account.\n2. Open the **Account** tab. Your API key is shown there.\n3. Copy the key and paste it here.", + "Current Month": "Current Month", + "Specific Month and Year": "Specific Month and Year", + "Current Week": "Current Week", + "Current Week from Date": "Current Week from Date", + "Current Week from Unix Timestamp": "Current Week from Unix Timestamp", + "Current Week only with Week Number": "Current Week only with Week Number", + "Current Week with Week Number and Year": "Current Week with Week Number and Year", + "Is Given Date is Weekend": "Is Given Date is Weekend", + "Is Given Date is Weekend, with Format": "Is Given Date is Weekend, with Format", + "Holidays in Specific Country and Year": "Holidays in Specific Country and Year", + "Convert Between Timezones with Date": "Convert Between Timezones with Date", + "Convert Between Timezones with Unix Timestamp": "Convert Between Timezones with Unix Timestamp", + "Convert CSV to JSON": "Convert CSV to JSON", + "Convert Currency": "Convert Currency", + "Convert IP to Geo": "Convert IP to Geo", + "Convert ISO to Nation": "Convert ISO to Nation", + "Convert Nation to ISO": "Convert Nation to ISO", + "Calculate BMI": "Calculate BMI", + "Calculate Geo Distance": "Calculate Geo Distance", + "Validate a bic": "Validate a bic", + "Validates an email and correct it": "Validates an email and correct it", + "Validate an IBAN": "Validate an IBAN", + "Validate vat with Countrycode and plain ID": "Validate vat with Countrycode and plain ID", + "Checks validity of a VAT ID": "Checks validity of a VAT ID", + "Lookup VAT Rates": "Lookup VAT Rates", + "Verify a Domain": "Verify a Domain", + "Detect Gender": "Detect Gender", + "Split Name": "Split Name", + "Text Contains": "Text Contains", + "Get a Random City": "Get a Random City", + "Get Random Name": "Get Random Name", + "Get Random Name with Gender": "Get Random Name with Gender", + "Get a Number": "Get a Number", + "Custom API Call": "Custom API Call", + "Get the days, workdays, Saturdays and Sundays of the current month.": "Get the days, workdays, Saturdays and Sundays of the current month.", + "Get the days, workdays, Saturdays and Sundays of a chosen month.": "Get the days, workdays, Saturdays and Sundays of a chosen month.", + "Get the calendar week number and the first and last day of the current week.": "Get the calendar week number and the first and last day of the current week.", + "Get the calendar week number and the first and last day of the week a date falls in.": "Get the calendar week number and the first and last day of the week a date falls in.", + "Get the calendar week number and the first and last day of the week a Unix timestamp falls in.": "Get the calendar week number and the first and last day of the week a Unix timestamp falls in.", + "Get the first and last day of a calendar week in the current year.": "Get the first and last day of a calendar week in the current year.", + "Get the first and last day of a calendar week in a chosen year.": "Get the first and last day of a calendar week in a chosen year.", + "Check whether a date falls on a Saturday or Sunday, and get its weekday.": "Check whether a date falls on a Saturday or Sunday, and get its weekday.", + "Check whether a date written in a custom format falls on a weekend, and get its weekday.": "Check whether a date written in a custom format falls on a weekend, and get its weekday.", + "List the public holidays of a country, and optionally a state, for a year.": "List the public holidays of a country, and optionally a state, for a year.", + "Convert a date and time from one time zone to another.": "Convert a date and time from one time zone to another.", + "Show a Unix timestamp as a date and time in another time zone.": "Show a Unix timestamp as a date and time in another time zone.", + "Turn CSV text into a list of rows you can loop over.": "Turn CSV text into a list of rows you can loop over.", + "Convert an amount from one currency to another at the latest exchange rate.": "Convert an amount from one currency to another at the latest exchange rate.", + "Look up the country, city, coordinates and provider of an IP address.": "Look up the country, city, coordinates and provider of an IP address.", + "Get the English country name for an ISO country code.": "Get the English country name for an ISO country code.", + "Get the ISO country code for an English country name.": "Get the ISO country code for an English country name.", + "Calculate body mass index, desirable weight and daily nutrient needs from height and weight.": "Calculate body mass index, desirable weight and daily nutrient needs from height and weight.", + "Get the travel distance and time between two addresses.": "Get the travel distance and time between two addresses.", + "Check whether a BIC (SWIFT code) is valid.": "Check whether a BIC (SWIFT code) is valid.", + "Check whether an email address is valid and get a corrected version of it.": "Check whether an email address is valid and get a corrected version of it.", + "Check whether an IBAN bank account number is valid.": "Check whether an IBAN bank account number is valid.", + "Check an EU VAT number against VIES, with the country and the number given separately.": "Check an EU VAT number against VIES, with the country and the number given separately.", + "Check a full EU VAT ID, including its country prefix, against VIES.": "Check a full EU VAT ID, including its country prefix, against VIES.", + "Get the standard and reduced VAT rates of an EU country.": "Get the standard and reduced VAT rates of an EU country.", + "Check whether a domain or website is reachable and get its main domain.": "Check whether a domain or website is reachable and get its main domain.", + "Guess whether a first name is male, female or unisex.": "Guess whether a first name is male, female or unisex.", + "Split a full name into first name and last name.": "Split a full name into first name and last name.", + "Check whether a text contains one or more keywords, and where.": "Check whether a text contains one or more keywords, and where.", + "Generate a random, fictional city and country name for test data.": "Generate a random, fictional city and country name for test data.", + "Generate a random person name for test data.": "Generate a random person name for test data.", + "Generate a random male or female person name for test data.": "Generate a random male or female person name for test data.", + "Generate a random number between a minimum and a maximum.": "Generate a random number between a minimum and a maximum.", + "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", + "Output Date Format": "Output Date Format", + "Return Unix Timestamps": "Return Unix Timestamps", + "Month": "Month", + "Year": "Year", + "Date": "Date", + "Date Format": "Date Format", + "Unix Timestamp": "Unix Timestamp", + "Week Number": "Week Number", + "Time Zone": "Time Zone", + "Country Code": "Country Code", + "State": "State", + "Date and Time": "Date and Time", + "From Time Zone": "From Time Zone", + "To Time Zone": "To Time Zone", + "Output Format": "Output Format", + "CSV": "CSV", + "Delimiter": "Delimiter", + "First Row Is Data": "First Row Is Data", + "Trim Spaces": "Trim Spaces", + "Skip Empty Values": "Skip Empty Values", + "Amount": "Amount", + "From Currency": "From Currency", + "To Currency": "To Currency", + "Rate Date Format": "Rate Date Format", + "IP Address": "IP Address", + "ISO Country Code": "ISO Country Code", + "Country Name": "Country Name", + "Weight (kg)": "Weight (kg)", + "Height (cm)": "Height (cm)", + "Start Address": "Start Address", + "End Address": "End Address", + "Travel Mode": "Travel Mode", + "BIC": "BIC", + "Email": "Email", + "IBAN": "IBAN", + "Country": "Country", + "VAT Number": "VAT Number", + "VAT ID": "VAT ID", + "Domain": "Domain", + "First Name": "First Name", + "Full Name": "Full Name", + "Last Name First": "Last Name First", + "Text": "Text", + "Keywords": "Keywords", + "Case Sensitive": "Case Sensitive", + "Whole Words Only": "Whole Words Only", + "Gender": "Gender", + "Minimum": "Minimum", + "Maximum": "Maximum", + "Number Type": "Number Type", + "Decimal Places": "Decimal Places", + "Method": "Method", + "Headers": "Headers", + "Query Parameters": "Query Parameters", + "Body Type": "Body Type", + "Body": "Body", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", + "Follow redirects": "Follow redirects", + "How dates in the result are written, using tokens such as `DD` (day), `MM` (month), `YYYY` (year), `HH:mm` (time). Leave empty to use the 0CodeKit default.": "How dates in the result are written, using tokens such as `DD` (day), `MM` (month), `YYYY` (year), `HH:mm` (time). Leave empty to use the 0CodeKit default.", + "Return every date as a Unix timestamp (seconds) instead of formatted text.": "Return every date as a Unix timestamp (seconds) instead of formatted text.", + "The month to describe.": "The month to describe.", + "The four-digit year, for example 2025.": "The four-digit year, for example 2025.", + "The date to look up, written in the format below.": "The date to look up, written in the format below.", + "How the date above is written, using tokens such as `DD` (day), `MM` (month) and `YYYY` (year). For example `DD.MM.YYYY` for 20.01.2025 or `YYYY-MM-DD` for 2025-01-20.": "How the date above is written, using tokens such as `DD` (day), `MM` (month) and `YYYY` (year). For example `DD.MM.YYYY` for 20.01.2025 or `YYYY-MM-DD` for 2025-01-20.", + "The moment to look up, in seconds since 1 January 1970.": "The moment to look up, in seconds since 1 January 1970.", + "The calendar week number, from 1 to 53.": "The calendar week number, from 1 to 53.", + "The date to check, for example 2025-01-20.": "The date to check, for example 2025-01-20.", + "The time zone the date belongs to. Leave empty to use the 0CodeKit default.": "The time zone the date belongs to. Leave empty to use the 0CodeKit default.", + "The date to check, written in the format below.": "The date to check, written in the format below.", + "How the date above is written, using `YYYY` or `YY` (year), `MM` or `M` (month) and `DD` or `D` (day), with any separators. For example `DD.MM.YYYY` for 20.01.2025.": "How the date above is written, using `YYYY` or `YY` (year), `MM` or `M` (month) and `DD` or `D` (day), with any separators. For example `DD.MM.YYYY` for 20.01.2025.", + "The two-letter ISO country code of the country.": "The two-letter ISO country code of the country.", + "A state or region code to add regional holidays. Empty: national only.": "A state or region code to add regional holidays. Empty: national only.", + "The date and time to convert, for example `2025-01-20 14:30`.": "The date and time to convert, for example `2025-01-20 14:30`.", + "The time zone the date and time above is in.": "The time zone the date and time above is in.", + "The time zone to convert to.": "The time zone to convert to.", + "How the converted time is written, using tokens such as `DD.MM.YYYY HH:mm`. Leave empty to use the 0CodeKit default.": "How the converted time is written, using tokens such as `DD.MM.YYYY HH:mm`. Leave empty to use the 0CodeKit default.", + "The moment to convert, in seconds since 1 January 1970.": "The moment to convert, in seconds since 1 January 1970.", + "The time zone of the timestamp. Unix timestamps are normally UTC.": "The time zone of the timestamp. Unix timestamps are normally UTC.", + "The CSV content to convert, including the header row.": "The CSV content to convert, including the header row.", + "The character that separates columns.": "The character that separates columns.", + "Turn on when the CSV has no header row; columns become field1, field2.": "Turn on when the CSV has no header row; columns become field1, field2.", + "Remove spaces around each value.": "Remove spaces around each value.", + "Leave empty cells out of each row instead of returning empty text.": "Leave empty cells out of each row instead of returning empty text.", + "The amount to convert.": "The amount to convert.", + "The three-letter currency code of the amount, for example `USD`.": "The three-letter currency code of the amount, for example `USD`.", + "The three-letter currency code to convert to, for example `EUR`.": "The three-letter currency code to convert to, for example `EUR`.", + "How the rate date is written, using `DD`, `MM` and `YYYY` tokens.": "How the rate date is written, using `DD`, `MM` and `YYYY` tokens.", + "The public IP address to look up.": "The public IP address to look up.", + "The ISO country code, for example `DE`.": "The ISO country code, for example `DE`.", + "The country name in English, for example `Germany`.": "The country name in English, for example `Germany`.", + "Body weight in kilograms.": "Body weight in kilograms.", + "Body height in centimetres.": "Body height in centimetres.", + "Where the trip starts. An address, city or place name.": "Where the trip starts. An address, city or place name.", + "Where the trip ends. An address, city or place name.": "Where the trip ends. An address, city or place name.", + "How the trip is made. This changes the route and the travel time.": "How the trip is made. This changes the route and the travel time.", + "The BIC or SWIFT code to check.": "The BIC or SWIFT code to check.", + "The email address to check.": "The email address to check.", + "The IBAN to check.": "The IBAN to check.", + "The EU member state that issued the VAT ID.": "The EU member state that issued the VAT ID.", + "The VAT number without the country prefix, for example `07643520567`.": "The VAT number without the country prefix, for example `07643520567`.", + "The full VAT ID, starting with the two-letter country code.": "The full VAT ID, starting with the two-letter country code.", + "The EU member state to look up.": "The EU member state to look up.", + "The domain or website to check, for example `https://www.example.com`.": "The domain or website to check, for example `https://www.example.com`.", + "The first name to check. Fill this or Full Name.": "The first name to check. Fill this or Full Name.", + "Used when First Name is empty; its first word is the first name.": "Used when First Name is empty; its first word is the first name.", + "The full name to split.": "The full name to split.", + "Turn on when the name is written with the last name first.": "Turn on when the name is written with the last name first.", + "The text to search in.": "The text to search in.", + "The words or phrases to look for.": "The words or phrases to look for.", + "Turn on to treat \"Apple\" and \"apple\" as different words.": "Turn on to treat \"Apple\" and \"apple\" as different words.", + "Turn on so that \"cat\" does not match inside \"category\".": "Turn on so that \"cat\" does not match inside \"category\".", + "The gender of the generated name.": "The gender of the generated name.", + "The smallest number that can be returned.": "The smallest number that can be returned.", + "The largest number that can be returned.": "The largest number that can be returned.", + "Whether to return a whole number or a decimal number.": "Whether to return a whole number or a decimal number.", + "Digits after the decimal point. Only used for decimal numbers.": "Digits after the decimal point. Only used for decimal numbers.", + "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.", + "January": "January", + "February": "February", + "March": "March", + "April": "April", + "May": "May", + "June": "June", + "July": "July", + "August": "August", + "September": "September", + "October": "October", + "November": "November", + "December": "December", + "UTC": "UTC", + "Africa/Abidjan": "Africa/Abidjan", + "Africa/Accra": "Africa/Accra", + "Africa/Addis_Ababa": "Africa/Addis_Ababa", + "Africa/Algiers": "Africa/Algiers", + "Africa/Asmera": "Africa/Asmera", + "Africa/Bamako": "Africa/Bamako", + "Africa/Bangui": "Africa/Bangui", + "Africa/Banjul": "Africa/Banjul", + "Africa/Bissau": "Africa/Bissau", + "Africa/Blantyre": "Africa/Blantyre", + "Africa/Brazzaville": "Africa/Brazzaville", + "Africa/Bujumbura": "Africa/Bujumbura", + "Africa/Cairo": "Africa/Cairo", + "Africa/Casablanca": "Africa/Casablanca", + "Africa/Ceuta": "Africa/Ceuta", + "Africa/Conakry": "Africa/Conakry", + "Africa/Dakar": "Africa/Dakar", + "Africa/Dar_es_Salaam": "Africa/Dar_es_Salaam", + "Africa/Djibouti": "Africa/Djibouti", + "Africa/Douala": "Africa/Douala", + "Africa/El_Aaiun": "Africa/El_Aaiun", + "Africa/Freetown": "Africa/Freetown", + "Africa/Gaborone": "Africa/Gaborone", + "Africa/Harare": "Africa/Harare", + "Africa/Johannesburg": "Africa/Johannesburg", + "Africa/Juba": "Africa/Juba", + "Africa/Kampala": "Africa/Kampala", + "Africa/Khartoum": "Africa/Khartoum", + "Africa/Kigali": "Africa/Kigali", + "Africa/Kinshasa": "Africa/Kinshasa", + "Africa/Lagos": "Africa/Lagos", + "Africa/Libreville": "Africa/Libreville", + "Africa/Lome": "Africa/Lome", + "Africa/Luanda": "Africa/Luanda", + "Africa/Lubumbashi": "Africa/Lubumbashi", + "Africa/Lusaka": "Africa/Lusaka", + "Africa/Malabo": "Africa/Malabo", + "Africa/Maputo": "Africa/Maputo", + "Africa/Maseru": "Africa/Maseru", + "Africa/Mbabane": "Africa/Mbabane", + "Africa/Mogadishu": "Africa/Mogadishu", + "Africa/Monrovia": "Africa/Monrovia", + "Africa/Nairobi": "Africa/Nairobi", + "Africa/Ndjamena": "Africa/Ndjamena", + "Africa/Niamey": "Africa/Niamey", + "Africa/Nouakchott": "Africa/Nouakchott", + "Africa/Ouagadougou": "Africa/Ouagadougou", + "Africa/Porto-Novo": "Africa/Porto-Novo", + "Africa/Sao_Tome": "Africa/Sao_Tome", + "Africa/Tripoli": "Africa/Tripoli", + "Africa/Tunis": "Africa/Tunis", + "Africa/Windhoek": "Africa/Windhoek", + "America/Adak": "America/Adak", + "America/Anchorage": "America/Anchorage", + "America/Anguilla": "America/Anguilla", + "America/Antigua": "America/Antigua", + "America/Araguaina": "America/Araguaina", + "America/Argentina/La_Rioja": "America/Argentina/La_Rioja", + "America/Argentina/Rio_Gallegos": "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta": "America/Argentina/Salta", + "America/Argentina/San_Juan": "America/Argentina/San_Juan", + "America/Argentina/San_Luis": "America/Argentina/San_Luis", + "America/Argentina/Tucuman": "America/Argentina/Tucuman", + "America/Argentina/Ushuaia": "America/Argentina/Ushuaia", + "America/Aruba": "America/Aruba", + "America/Asuncion": "America/Asuncion", + "America/Bahia": "America/Bahia", + "America/Bahia_Banderas": "America/Bahia_Banderas", + "America/Barbados": "America/Barbados", + "America/Belem": "America/Belem", + "America/Belize": "America/Belize", + "America/Blanc-Sablon": "America/Blanc-Sablon", + "America/Boa_Vista": "America/Boa_Vista", + "America/Bogota": "America/Bogota", + "America/Boise": "America/Boise", + "America/Buenos_Aires": "America/Buenos_Aires", + "America/Cambridge_Bay": "America/Cambridge_Bay", + "America/Campo_Grande": "America/Campo_Grande", + "America/Cancun": "America/Cancun", + "America/Caracas": "America/Caracas", + "America/Catamarca": "America/Catamarca", + "America/Cayenne": "America/Cayenne", + "America/Cayman": "America/Cayman", + "America/Chicago": "America/Chicago", + "America/Chihuahua": "America/Chihuahua", + "America/Ciudad_Juarez": "America/Ciudad_Juarez", + "America/Coral_Harbour": "America/Coral_Harbour", + "America/Cordoba": "America/Cordoba", + "America/Costa_Rica": "America/Costa_Rica", + "America/Coyhaique": "America/Coyhaique", + "America/Creston": "America/Creston", + "America/Cuiaba": "America/Cuiaba", + "America/Curacao": "America/Curacao", + "America/Danmarkshavn": "America/Danmarkshavn", + "America/Dawson": "America/Dawson", + "America/Dawson_Creek": "America/Dawson_Creek", + "America/Denver": "America/Denver", + "America/Detroit": "America/Detroit", + "America/Dominica": "America/Dominica", + "America/Edmonton": "America/Edmonton", + "America/Eirunepe": "America/Eirunepe", + "America/El_Salvador": "America/El_Salvador", + "America/Fort_Nelson": "America/Fort_Nelson", + "America/Fortaleza": "America/Fortaleza", + "America/Glace_Bay": "America/Glace_Bay", + "America/Godthab": "America/Godthab", + "America/Goose_Bay": "America/Goose_Bay", + "America/Grand_Turk": "America/Grand_Turk", + "America/Grenada": "America/Grenada", + "America/Guadeloupe": "America/Guadeloupe", + "America/Guatemala": "America/Guatemala", + "America/Guayaquil": "America/Guayaquil", + "America/Guyana": "America/Guyana", + "America/Halifax": "America/Halifax", + "America/Havana": "America/Havana", + "America/Hermosillo": "America/Hermosillo", + "America/Indiana/Knox": "America/Indiana/Knox", + "America/Indiana/Marengo": "America/Indiana/Marengo", + "America/Indiana/Petersburg": "America/Indiana/Petersburg", + "America/Indiana/Tell_City": "America/Indiana/Tell_City", + "America/Indiana/Vevay": "America/Indiana/Vevay", + "America/Indiana/Vincennes": "America/Indiana/Vincennes", + "America/Indiana/Winamac": "America/Indiana/Winamac", + "America/Indianapolis": "America/Indianapolis", + "America/Inuvik": "America/Inuvik", + "America/Iqaluit": "America/Iqaluit", + "America/Jamaica": "America/Jamaica", + "America/Jujuy": "America/Jujuy", + "America/Juneau": "America/Juneau", + "America/Kentucky/Monticello": "America/Kentucky/Monticello", + "America/Kralendijk": "America/Kralendijk", + "America/La_Paz": "America/La_Paz", + "America/Lima": "America/Lima", + "America/Los_Angeles": "America/Los_Angeles", + "America/Louisville": "America/Louisville", + "America/Lower_Princes": "America/Lower_Princes", + "America/Maceio": "America/Maceio", + "America/Managua": "America/Managua", + "America/Manaus": "America/Manaus", + "America/Marigot": "America/Marigot", + "America/Martinique": "America/Martinique", + "America/Matamoros": "America/Matamoros", + "America/Mazatlan": "America/Mazatlan", + "America/Mendoza": "America/Mendoza", + "America/Menominee": "America/Menominee", + "America/Merida": "America/Merida", + "America/Metlakatla": "America/Metlakatla", + "America/Mexico_City": "America/Mexico_City", + "America/Miquelon": "America/Miquelon", + "America/Moncton": "America/Moncton", + "America/Monterrey": "America/Monterrey", + "America/Montevideo": "America/Montevideo", + "America/Montserrat": "America/Montserrat", + "America/Nassau": "America/Nassau", + "America/New_York": "America/New_York", + "America/Nome": "America/Nome", + "America/Noronha": "America/Noronha", + "America/North_Dakota/Beulah": "America/North_Dakota/Beulah", + "America/North_Dakota/Center": "America/North_Dakota/Center", + "America/North_Dakota/New_Salem": "America/North_Dakota/New_Salem", + "America/Ojinaga": "America/Ojinaga", + "America/Panama": "America/Panama", + "America/Paramaribo": "America/Paramaribo", + "America/Phoenix": "America/Phoenix", + "America/Port-au-Prince": "America/Port-au-Prince", + "America/Port_of_Spain": "America/Port_of_Spain", + "America/Porto_Velho": "America/Porto_Velho", + "America/Puerto_Rico": "America/Puerto_Rico", + "America/Punta_Arenas": "America/Punta_Arenas", + "America/Rankin_Inlet": "America/Rankin_Inlet", + "America/Recife": "America/Recife", + "America/Regina": "America/Regina", + "America/Resolute": "America/Resolute", + "America/Rio_Branco": "America/Rio_Branco", + "America/Santarem": "America/Santarem", + "America/Santiago": "America/Santiago", + "America/Santo_Domingo": "America/Santo_Domingo", + "America/Sao_Paulo": "America/Sao_Paulo", + "America/Scoresbysund": "America/Scoresbysund", + "America/Sitka": "America/Sitka", + "America/St_Barthelemy": "America/St_Barthelemy", + "America/St_Johns": "America/St_Johns", + "America/St_Kitts": "America/St_Kitts", + "America/St_Lucia": "America/St_Lucia", + "America/St_Thomas": "America/St_Thomas", + "America/St_Vincent": "America/St_Vincent", + "America/Swift_Current": "America/Swift_Current", + "America/Tegucigalpa": "America/Tegucigalpa", + "America/Thule": "America/Thule", + "America/Tijuana": "America/Tijuana", + "America/Toronto": "America/Toronto", + "America/Tortola": "America/Tortola", + "America/Vancouver": "America/Vancouver", + "America/Whitehorse": "America/Whitehorse", + "America/Winnipeg": "America/Winnipeg", + "America/Yakutat": "America/Yakutat", + "Antarctica/Casey": "Antarctica/Casey", + "Antarctica/Davis": "Antarctica/Davis", + "Antarctica/DumontDUrville": "Antarctica/DumontDUrville", + "Antarctica/Macquarie": "Antarctica/Macquarie", + "Antarctica/Mawson": "Antarctica/Mawson", + "Antarctica/McMurdo": "Antarctica/McMurdo", + "Antarctica/Palmer": "Antarctica/Palmer", + "Antarctica/Rothera": "Antarctica/Rothera", + "Antarctica/Syowa": "Antarctica/Syowa", + "Antarctica/Troll": "Antarctica/Troll", + "Antarctica/Vostok": "Antarctica/Vostok", + "Arctic/Longyearbyen": "Arctic/Longyearbyen", + "Asia/Aden": "Asia/Aden", + "Asia/Almaty": "Asia/Almaty", + "Asia/Amman": "Asia/Amman", + "Asia/Anadyr": "Asia/Anadyr", + "Asia/Aqtau": "Asia/Aqtau", + "Asia/Aqtobe": "Asia/Aqtobe", + "Asia/Ashgabat": "Asia/Ashgabat", + "Asia/Atyrau": "Asia/Atyrau", + "Asia/Baghdad": "Asia/Baghdad", + "Asia/Bahrain": "Asia/Bahrain", + "Asia/Baku": "Asia/Baku", + "Asia/Bangkok": "Asia/Bangkok", + "Asia/Barnaul": "Asia/Barnaul", + "Asia/Beirut": "Asia/Beirut", + "Asia/Bishkek": "Asia/Bishkek", + "Asia/Brunei": "Asia/Brunei", + "Asia/Calcutta": "Asia/Calcutta", + "Asia/Chita": "Asia/Chita", + "Asia/Colombo": "Asia/Colombo", + "Asia/Damascus": "Asia/Damascus", + "Asia/Dhaka": "Asia/Dhaka", + "Asia/Dili": "Asia/Dili", + "Asia/Dubai": "Asia/Dubai", + "Asia/Dushanbe": "Asia/Dushanbe", + "Asia/Famagusta": "Asia/Famagusta", + "Asia/Gaza": "Asia/Gaza", + "Asia/Hebron": "Asia/Hebron", + "Asia/Hong_Kong": "Asia/Hong_Kong", + "Asia/Hovd": "Asia/Hovd", + "Asia/Irkutsk": "Asia/Irkutsk", + "Asia/Jakarta": "Asia/Jakarta", + "Asia/Jayapura": "Asia/Jayapura", + "Asia/Jerusalem": "Asia/Jerusalem", + "Asia/Kabul": "Asia/Kabul", + "Asia/Kamchatka": "Asia/Kamchatka", + "Asia/Karachi": "Asia/Karachi", + "Asia/Katmandu": "Asia/Katmandu", + "Asia/Khandyga": "Asia/Khandyga", + "Asia/Krasnoyarsk": "Asia/Krasnoyarsk", + "Asia/Kuala_Lumpur": "Asia/Kuala_Lumpur", + "Asia/Kuching": "Asia/Kuching", + "Asia/Kuwait": "Asia/Kuwait", + "Asia/Macau": "Asia/Macau", + "Asia/Magadan": "Asia/Magadan", + "Asia/Makassar": "Asia/Makassar", + "Asia/Manila": "Asia/Manila", + "Asia/Muscat": "Asia/Muscat", + "Asia/Nicosia": "Asia/Nicosia", + "Asia/Novokuznetsk": "Asia/Novokuznetsk", + "Asia/Novosibirsk": "Asia/Novosibirsk", + "Asia/Omsk": "Asia/Omsk", + "Asia/Oral": "Asia/Oral", + "Asia/Phnom_Penh": "Asia/Phnom_Penh", + "Asia/Pontianak": "Asia/Pontianak", + "Asia/Pyongyang": "Asia/Pyongyang", + "Asia/Qatar": "Asia/Qatar", + "Asia/Qostanay": "Asia/Qostanay", + "Asia/Qyzylorda": "Asia/Qyzylorda", + "Asia/Rangoon": "Asia/Rangoon", + "Asia/Riyadh": "Asia/Riyadh", + "Asia/Saigon": "Asia/Saigon", + "Asia/Sakhalin": "Asia/Sakhalin", + "Asia/Samarkand": "Asia/Samarkand", + "Asia/Seoul": "Asia/Seoul", + "Asia/Shanghai": "Asia/Shanghai", + "Asia/Singapore": "Asia/Singapore", + "Asia/Srednekolymsk": "Asia/Srednekolymsk", + "Asia/Taipei": "Asia/Taipei", + "Asia/Tashkent": "Asia/Tashkent", + "Asia/Tbilisi": "Asia/Tbilisi", + "Asia/Tehran": "Asia/Tehran", + "Asia/Thimphu": "Asia/Thimphu", + "Asia/Tokyo": "Asia/Tokyo", + "Asia/Tomsk": "Asia/Tomsk", + "Asia/Ulaanbaatar": "Asia/Ulaanbaatar", + "Asia/Urumqi": "Asia/Urumqi", + "Asia/Ust-Nera": "Asia/Ust-Nera", + "Asia/Vientiane": "Asia/Vientiane", + "Asia/Vladivostok": "Asia/Vladivostok", + "Asia/Yakutsk": "Asia/Yakutsk", + "Asia/Yekaterinburg": "Asia/Yekaterinburg", + "Asia/Yerevan": "Asia/Yerevan", + "Atlantic/Azores": "Atlantic/Azores", + "Atlantic/Bermuda": "Atlantic/Bermuda", + "Atlantic/Canary": "Atlantic/Canary", + "Atlantic/Cape_Verde": "Atlantic/Cape_Verde", + "Atlantic/Faeroe": "Atlantic/Faeroe", + "Atlantic/Madeira": "Atlantic/Madeira", + "Atlantic/Reykjavik": "Atlantic/Reykjavik", + "Atlantic/South_Georgia": "Atlantic/South_Georgia", + "Atlantic/St_Helena": "Atlantic/St_Helena", + "Atlantic/Stanley": "Atlantic/Stanley", + "Australia/Adelaide": "Australia/Adelaide", + "Australia/Brisbane": "Australia/Brisbane", + "Australia/Broken_Hill": "Australia/Broken_Hill", + "Australia/Darwin": "Australia/Darwin", + "Australia/Eucla": "Australia/Eucla", + "Australia/Hobart": "Australia/Hobart", + "Australia/Lindeman": "Australia/Lindeman", + "Australia/Lord_Howe": "Australia/Lord_Howe", + "Australia/Melbourne": "Australia/Melbourne", + "Australia/Perth": "Australia/Perth", + "Australia/Sydney": "Australia/Sydney", + "Europe/Amsterdam": "Europe/Amsterdam", + "Europe/Andorra": "Europe/Andorra", + "Europe/Astrakhan": "Europe/Astrakhan", + "Europe/Athens": "Europe/Athens", + "Europe/Belgrade": "Europe/Belgrade", + "Europe/Berlin": "Europe/Berlin", + "Europe/Bratislava": "Europe/Bratislava", + "Europe/Brussels": "Europe/Brussels", + "Europe/Bucharest": "Europe/Bucharest", + "Europe/Budapest": "Europe/Budapest", + "Europe/Busingen": "Europe/Busingen", + "Europe/Chisinau": "Europe/Chisinau", + "Europe/Copenhagen": "Europe/Copenhagen", + "Europe/Dublin": "Europe/Dublin", + "Europe/Gibraltar": "Europe/Gibraltar", + "Europe/Guernsey": "Europe/Guernsey", + "Europe/Helsinki": "Europe/Helsinki", + "Europe/Isle_of_Man": "Europe/Isle_of_Man", + "Europe/Istanbul": "Europe/Istanbul", + "Europe/Jersey": "Europe/Jersey", + "Europe/Kaliningrad": "Europe/Kaliningrad", + "Europe/Kiev": "Europe/Kiev", + "Europe/Kirov": "Europe/Kirov", + "Europe/Lisbon": "Europe/Lisbon", + "Europe/Ljubljana": "Europe/Ljubljana", + "Europe/London": "Europe/London", + "Europe/Luxembourg": "Europe/Luxembourg", + "Europe/Madrid": "Europe/Madrid", + "Europe/Malta": "Europe/Malta", + "Europe/Mariehamn": "Europe/Mariehamn", + "Europe/Minsk": "Europe/Minsk", + "Europe/Monaco": "Europe/Monaco", + "Europe/Moscow": "Europe/Moscow", + "Europe/Oslo": "Europe/Oslo", + "Europe/Paris": "Europe/Paris", + "Europe/Podgorica": "Europe/Podgorica", + "Europe/Prague": "Europe/Prague", + "Europe/Riga": "Europe/Riga", + "Europe/Rome": "Europe/Rome", + "Europe/Samara": "Europe/Samara", + "Europe/San_Marino": "Europe/San_Marino", + "Europe/Sarajevo": "Europe/Sarajevo", + "Europe/Saratov": "Europe/Saratov", + "Europe/Simferopol": "Europe/Simferopol", + "Europe/Skopje": "Europe/Skopje", + "Europe/Sofia": "Europe/Sofia", + "Europe/Stockholm": "Europe/Stockholm", + "Europe/Tallinn": "Europe/Tallinn", + "Europe/Tirane": "Europe/Tirane", + "Europe/Ulyanovsk": "Europe/Ulyanovsk", + "Europe/Vaduz": "Europe/Vaduz", + "Europe/Vatican": "Europe/Vatican", + "Europe/Vienna": "Europe/Vienna", + "Europe/Vilnius": "Europe/Vilnius", + "Europe/Volgograd": "Europe/Volgograd", + "Europe/Warsaw": "Europe/Warsaw", + "Europe/Zagreb": "Europe/Zagreb", + "Europe/Zurich": "Europe/Zurich", + "Indian/Antananarivo": "Indian/Antananarivo", + "Indian/Chagos": "Indian/Chagos", + "Indian/Christmas": "Indian/Christmas", + "Indian/Cocos": "Indian/Cocos", + "Indian/Comoro": "Indian/Comoro", + "Indian/Kerguelen": "Indian/Kerguelen", + "Indian/Mahe": "Indian/Mahe", + "Indian/Maldives": "Indian/Maldives", + "Indian/Mauritius": "Indian/Mauritius", + "Indian/Mayotte": "Indian/Mayotte", + "Indian/Reunion": "Indian/Reunion", + "Pacific/Apia": "Pacific/Apia", + "Pacific/Auckland": "Pacific/Auckland", + "Pacific/Bougainville": "Pacific/Bougainville", + "Pacific/Chatham": "Pacific/Chatham", + "Pacific/Easter": "Pacific/Easter", + "Pacific/Efate": "Pacific/Efate", + "Pacific/Enderbury": "Pacific/Enderbury", + "Pacific/Fakaofo": "Pacific/Fakaofo", + "Pacific/Fiji": "Pacific/Fiji", + "Pacific/Funafuti": "Pacific/Funafuti", + "Pacific/Galapagos": "Pacific/Galapagos", + "Pacific/Gambier": "Pacific/Gambier", + "Pacific/Guadalcanal": "Pacific/Guadalcanal", + "Pacific/Guam": "Pacific/Guam", + "Pacific/Honolulu": "Pacific/Honolulu", + "Pacific/Kiritimati": "Pacific/Kiritimati", + "Pacific/Kosrae": "Pacific/Kosrae", + "Pacific/Kwajalein": "Pacific/Kwajalein", + "Pacific/Majuro": "Pacific/Majuro", + "Pacific/Marquesas": "Pacific/Marquesas", + "Pacific/Midway": "Pacific/Midway", + "Pacific/Nauru": "Pacific/Nauru", + "Pacific/Niue": "Pacific/Niue", + "Pacific/Norfolk": "Pacific/Norfolk", + "Pacific/Noumea": "Pacific/Noumea", + "Pacific/Pago_Pago": "Pacific/Pago_Pago", + "Pacific/Palau": "Pacific/Palau", + "Pacific/Pitcairn": "Pacific/Pitcairn", + "Pacific/Ponape": "Pacific/Ponape", + "Pacific/Port_Moresby": "Pacific/Port_Moresby", + "Pacific/Rarotonga": "Pacific/Rarotonga", + "Pacific/Saipan": "Pacific/Saipan", + "Pacific/Tahiti": "Pacific/Tahiti", + "Pacific/Tarawa": "Pacific/Tarawa", + "Pacific/Tongatapu": "Pacific/Tongatapu", + "Pacific/Truk": "Pacific/Truk", + "Pacific/Wake": "Pacific/Wake", + "Pacific/Wallis": "Pacific/Wallis", + "Detect automatically": "Detect automatically", + "Comma (,)": "Comma (,)", + "Semicolon (;)": "Semicolon (;)", + "Tab": "Tab", + "Pipe (|)": "Pipe (|)", + "Driving": "Driving", + "Walking": "Walking", + "Bicycling": "Bicycling", + "Public transit": "Public transit", + "Austria (AT)": "Austria (AT)", + "Belgium (BE)": "Belgium (BE)", + "Bulgaria (BG)": "Bulgaria (BG)", + "Croatia (HR)": "Croatia (HR)", + "Cyprus (CY)": "Cyprus (CY)", + "Czechia (CZ)": "Czechia (CZ)", + "Denmark (DK)": "Denmark (DK)", + "Estonia (EE)": "Estonia (EE)", + "Finland (FI)": "Finland (FI)", + "France (FR)": "France (FR)", + "Germany (DE)": "Germany (DE)", + "Greece (EL)": "Greece (EL)", + "Hungary (HU)": "Hungary (HU)", + "Ireland (IE)": "Ireland (IE)", + "Italy (IT)": "Italy (IT)", + "Latvia (LV)": "Latvia (LV)", + "Lithuania (LT)": "Lithuania (LT)", + "Luxembourg (LU)": "Luxembourg (LU)", + "Malta (MT)": "Malta (MT)", + "Netherlands (NL)": "Netherlands (NL)", + "Poland (PL)": "Poland (PL)", + "Portugal (PT)": "Portugal (PT)", + "Romania (RO)": "Romania (RO)", + "Slovakia (SK)": "Slovakia (SK)", + "Slovenia (SI)": "Slovenia (SI)", + "Spain (ES)": "Spain (ES)", + "Sweden (SE)": "Sweden (SE)", + "Northern Ireland (XI)": "Northern Ireland (XI)", + "Greece (GR)": "Greece (GR)", + "Female": "Female", + "Male": "Male", + "Whole number": "Whole number", + "Decimal number": "Decimal number", + "GET": "GET", + "POST": "POST", + "PATCH": "PATCH", + "PUT": "PUT", + "DELETE": "DELETE", + "HEAD": "HEAD" +} \ No newline at end of file diff --git a/packages/pieces/community/0codekit/src/index.ts b/packages/pieces/community/0codekit/src/index.ts new file mode 100644 index 000000000000..dc59ad53e85d --- /dev/null +++ b/packages/pieces/community/0codekit/src/index.ts @@ -0,0 +1,92 @@ +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { lookupVatRatesAction } from './lib/actions/business/lookup-vat-rates'; +import { validateBicAction } from './lib/actions/business/validate-bic'; +import { validateEmailAction } from './lib/actions/business/validate-email'; +import { validateIbanAction } from './lib/actions/business/validate-iban'; +import { validateVatIdAction } from './lib/actions/business/validate-vat-id'; +import { validateVatWithCountryCodeAction } from './lib/actions/business/validate-vat-with-country-code'; +import { verifyDomainAction } from './lib/actions/business/verify-domain'; +import { calculateBmiAction } from './lib/actions/calculate/calculate-bmi'; +import { calculateGeoDistanceAction } from './lib/actions/calculate/calculate-geo-distance'; +import { convertCsvToJsonAction } from './lib/actions/convert/convert-csv-to-json'; +import { convertCurrencyAction } from './lib/actions/convert/convert-currency'; +import { convertIpToGeoAction } from './lib/actions/convert/convert-ip-to-geo'; +import { convertIsoToNationAction } from './lib/actions/convert/convert-iso-to-nation'; +import { convertNationToIsoAction } from './lib/actions/convert/convert-nation-to-iso'; +import { convertTimezoneWithDateAction } from './lib/actions/date-and-time/convert-timezone-with-date'; +import { convertTimezoneWithUnixTimestampAction } from './lib/actions/date-and-time/convert-timezone-with-unix-timestamp'; +import { currentMonthAction } from './lib/actions/date-and-time/current-month'; +import { currentWeekAction } from './lib/actions/date-and-time/current-week'; +import { currentWeekFromDateAction } from './lib/actions/date-and-time/current-week-from-date'; +import { currentWeekFromUnixTimestampAction } from './lib/actions/date-and-time/current-week-from-unix-timestamp'; +import { currentWeekWithWeekNumberAction } from './lib/actions/date-and-time/current-week-with-week-number'; +import { currentWeekWithWeekNumberAndYearAction } from './lib/actions/date-and-time/current-week-with-week-number-and-year'; +import { getHolidaysAction } from './lib/actions/date-and-time/get-holidays'; +import { isWeekendAction } from './lib/actions/date-and-time/is-weekend'; +import { isWeekendWithFormatAction } from './lib/actions/date-and-time/is-weekend-with-format'; +import { specificMonthAndYearAction } from './lib/actions/date-and-time/specific-month-and-year'; +import { getANumberAction } from './lib/actions/generate/get-a-number'; +import { getRandomCityAction } from './lib/actions/generate/get-random-city'; +import { getRandomNameAction } from './lib/actions/generate/get-random-name'; +import { getRandomNameWithGenderAction } from './lib/actions/generate/get-random-name-with-gender'; +import { detectGenderAction } from './lib/actions/text/detect-gender'; +import { splitNameAction } from './lib/actions/text/split-name'; +import { textContainsAction } from './lib/actions/text/text-contains'; +import { zeroCodeKitAuth } from './lib/auth'; +import { ZEROCODEKIT_BASE_URL, zeroCodeKitApi } from './lib/common/client'; + +export const zeroCodeKit = createPiece({ + displayName: '0CodeKit', + description: + 'Ready-made utilities for automations: dates and calendar weeks, conversions and validation.', + minimumSupportedRelease: '0.82.0', + logoUrl: 'https://cdn.activepieces.com/pieces/0codekit.png', + categories: [PieceCategory.DEVELOPER_TOOLS, PieceCategory.PRODUCTIVITY], + auth: zeroCodeKitAuth, + authors: ['OdaiAhmed99'], + actions: [ + currentMonthAction, + specificMonthAndYearAction, + currentWeekAction, + currentWeekFromDateAction, + currentWeekFromUnixTimestampAction, + currentWeekWithWeekNumberAction, + currentWeekWithWeekNumberAndYearAction, + isWeekendAction, + isWeekendWithFormatAction, + getHolidaysAction, + convertTimezoneWithDateAction, + convertTimezoneWithUnixTimestampAction, + convertCsvToJsonAction, + convertCurrencyAction, + convertIpToGeoAction, + convertIsoToNationAction, + convertNationToIsoAction, + calculateBmiAction, + calculateGeoDistanceAction, + validateBicAction, + validateEmailAction, + validateIbanAction, + validateVatWithCountryCodeAction, + validateVatIdAction, + lookupVatRatesAction, + verifyDomainAction, + detectGenderAction, + splitNameAction, + textContainsAction, + getRandomCityAction, + getRandomNameAction, + getRandomNameWithGenderAction, + getANumberAction, + createCustomApiCallAction({ + baseUrl: () => ZEROCODEKIT_BASE_URL, + auth: zeroCodeKitAuth, + authMapping: async (auth, propsValue) => { + zeroCodeKitApi.assertProviderUrl(propsValue['url']?.['url']); + return { auth: auth.secret_text }; + }, + }), + ], + triggers: [], +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/lookup-vat-rates.ts b/packages/pieces/community/0codekit/src/lib/actions/business/lookup-vat-rates.ts new file mode 100644 index 000000000000..c3708c8726d7 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/lookup-vat-rates.ts @@ -0,0 +1,55 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { zeroCodeKitProps } from '../../common/props'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const lookupVatRatesAction = createAction({ + auth: zeroCodeKitAuth, + name: 'lookup_vat_rates', + classification: 'READ', + displayName: 'Lookup VAT Rates', + description: 'Get the standard and reduced VAT rates of an EU country.', + audience: 'both', + aiMetadata: { + description: + 'Return the standard, reduced, super-reduced and parking VAT rates (in percent) of an EU member state. Rates a country does not have are returned as null. Read-only and safe to retry.', + idempotent: true, + }, + props: { + countryCode: zeroCodeKitProps.euCountry(), + }, + outputSchema: businessAiOutputSchemas.lookupVatRates, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/business/lookupvatrates', + body: { countryCode: propsValue.countryCode }, + }); + return { + country: response.country ?? null, + vat_name: response.vat_name ?? null, + vat_abbreviation: response.vat_abbr ?? null, + standard_rate: response.standard_rate ?? null, + reduced_rate: rateOrNull(response.reduced_rate), + reduced_rate_alternative: rateOrNull(response.reduced_rate_alt), + super_reduced_rate: rateOrNull(response.super_reduced_rate), + parking_rate: rateOrNull(response.parking_rate), + }; + }, +}); + +function rateOrNull(rate: number | boolean | undefined): number | null { + return typeof rate === 'number' ? rate : null; +} + +type VatRatesResponse = { + country?: string; + vat_name?: string; + vat_abbr?: string; + standard_rate?: number; + reduced_rate?: number | boolean; + reduced_rate_alt?: number | boolean; + super_reduced_rate?: number | boolean; + parking_rate?: number | boolean; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/validate-bic.ts b/packages/pieces/community/0codekit/src/lib/actions/business/validate-bic.ts new file mode 100644 index 000000000000..f416c8432d2c --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/validate-bic.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const validateBicAction = createAction({ + auth: zeroCodeKitAuth, + name: 'validate_bic', + classification: 'READ', + displayName: 'Validate a bic', + description: 'Check whether a BIC (SWIFT code) is valid.', + audience: 'both', + aiMetadata: { + description: + 'Check whether a BIC / SWIFT bank identifier code (8 or 11 characters) is valid. Returns only a true/false result. Read-only and safe to retry.', + idempotent: true, + }, + props: { + bic: Property.ShortText({ + displayName: 'BIC', + description: 'The BIC or SWIFT code to check.', + required: true, + placeholder: 'DEUTDEFF', + }), + }, + outputSchema: businessAiOutputSchemas.validateBic, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/business/validate/bic', + body: { bic: propsValue.bic.trim() }, + }); + return { valid: response.valid ?? null }; + }, +}); + +type ValidityResponse = { + valid?: boolean; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/validate-email.ts b/packages/pieces/community/0codekit/src/lib/actions/business/validate-email.ts new file mode 100644 index 000000000000..011e864ba566 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/validate-email.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const validateEmailAction = createAction({ + auth: zeroCodeKitAuth, + name: 'validate_email', + classification: 'READ', + displayName: 'Validates an email and correct it', + description: 'Check whether an email address is valid and get a corrected version of it.', + audience: 'both', + aiMetadata: { + description: + 'Check whether an email address is valid and return a corrected address when a typo can be fixed (for example gmial.com to gmail.com). Does not send any email. Read-only and safe to retry.', + idempotent: true, + }, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'The email address to check.', + required: true, + placeholder: 'jane@example.com', + }), + }, + outputSchema: businessAiOutputSchemas.validateEmail, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/business/validate/email', + body: { email: propsValue.email.trim() }, + }); + return { + valid: response.valid ?? null, + corrected_email: response.emailCorrected ?? null, + }; + }, +}); + +type EmailResponse = { + valid?: boolean; + emailCorrected?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/validate-iban.ts b/packages/pieces/community/0codekit/src/lib/actions/business/validate-iban.ts new file mode 100644 index 000000000000..d2d097d227e2 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/validate-iban.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const validateIbanAction = createAction({ + auth: zeroCodeKitAuth, + name: 'validate_iban', + classification: 'READ', + displayName: 'Validate an IBAN', + description: 'Check whether an IBAN bank account number is valid.', + audience: 'both', + aiMetadata: { + description: + 'Check whether an IBAN (International Bank Account Number) is valid. Returns only a true/false result. Read-only and safe to retry.', + idempotent: true, + }, + props: { + iban: Property.ShortText({ + displayName: 'IBAN', + description: 'The IBAN to check.', + required: true, + placeholder: 'DE02120300000000202051', + }), + }, + outputSchema: businessAiOutputSchemas.validateIban, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/business/validate/iban', + body: { iban: propsValue.iban.trim() }, + }); + return { valid: response.valid ?? null }; + }, +}); + +type ValidityResponse = { + valid?: boolean; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-id.ts b/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-id.ts new file mode 100644 index 000000000000..306c1cc37153 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-id.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { vatApi } from '../../common/vat'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const validateVatIdAction = createAction({ + auth: zeroCodeKitAuth, + name: 'validate_vat_id', + classification: 'READ', + displayName: 'Checks validity of a VAT ID', + description: 'Check a full EU VAT ID, including its country prefix, against VIES.', + audience: 'both', + aiMetadata: { + description: + 'Validate a full EU VAT ID that starts with its two-letter country prefix (for example DE123456789) against the VIES service, and return the registered company name and address when available. Use "Validate vat with Countrycode and plain ID" when the country and number are separate. Read-only and safe to retry.', + idempotent: true, + }, + props: { + vatId: Property.ShortText({ + displayName: 'VAT ID', + description: 'The full VAT ID, starting with the two-letter country code.', + required: true, + placeholder: 'DE123456789', + }), + }, + outputSchema: businessAiOutputSchemas.validateVatId, + async run({ auth, propsValue }) { + return vatApi.validate({ + apiKey: auth.secret_text, + body: { + vatId: propsValue.vatId.replace(/\s+/g, '').toUpperCase(), + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-with-country-code.ts b/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-with-country-code.ts new file mode 100644 index 000000000000..e567b57a3e40 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/validate-vat-with-country-code.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitProps } from '../../common/props'; +import { vatApi } from '../../common/vat'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const validateVatWithCountryCodeAction = createAction({ + auth: zeroCodeKitAuth, + name: 'validate_vat_with_country_code', + classification: 'READ', + displayName: 'Validate vat with Countrycode and plain ID', + description: 'Check an EU VAT number against VIES, with the country and the number given separately.', + audience: 'both', + aiMetadata: { + description: + 'Validate an EU VAT number against the VIES service when the country code and the number part are separate values, and return the registered company name and address when available. Use "Checks validity of a VAT ID" when you have the full ID with its country prefix. Read-only and safe to retry.', + idempotent: true, + }, + props: { + countryCode: zeroCodeKitProps.viesCountry(), + id: Property.ShortText({ + displayName: 'VAT Number', + description: 'The VAT number without the country prefix, for example `07643520567`.', + required: true, + placeholder: '07643520567', + }), + }, + outputSchema: businessAiOutputSchemas.validateVatWithCountryCode, + async run({ auth, propsValue }) { + return vatApi.validate({ + apiKey: auth.secret_text, + body: { + countryCode: propsValue.countryCode, + id: propsValue.id.replace(/\s+/g, ''), + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/business/verify-domain.ts b/packages/pieces/community/0codekit/src/lib/actions/business/verify-domain.ts new file mode 100644 index 000000000000..8c2b3b1fdc88 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/business/verify-domain.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const verifyDomainAction = createAction({ + auth: zeroCodeKitAuth, + name: 'verify_domain', + classification: 'READ', + displayName: 'Verify a Domain', + description: 'Check whether a domain or website is reachable and get its main domain.', + audience: 'both', + aiMetadata: { + description: + 'Check whether a domain or website URL is valid by requesting it, and return the main domain without subdomains. Read-only and safe to retry.', + idempotent: true, + }, + props: { + domain: Property.ShortText({ + displayName: 'Domain', + description: 'The domain or website to check, for example `https://www.example.com`.', + required: true, + placeholder: 'https://www.example.com', + }), + }, + outputSchema: businessAiOutputSchemas.verifyDomain, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/business/verify/domain', + body: { domain: propsValue.domain.trim() }, + }); + return { + is_valid: response.isValid ?? null, + main_domain: response.mainDomain ?? null, + }; + }, +}); + +type VerifyDomainResponse = { + mainDomain?: string; + isValid?: boolean; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-bmi.ts b/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-bmi.ts new file mode 100644 index 000000000000..4dc7d50a82ac --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-bmi.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const calculateBmiAction = createAction({ + auth: zeroCodeKitAuth, + name: 'calculate_bmi', + classification: 'READ', + displayName: 'Calculate BMI', + description: 'Calculate body mass index, desirable weight and daily nutrient needs from height and weight.', + audience: 'both', + aiMetadata: { + description: + 'Calculate BMI and its classification from weight in kilograms and height in centimetres, plus desirable body weight, daily calories and daily carbohydrate, protein and fat in grams. Convert pounds or inches first. Read-only and safe to retry.', + idempotent: true, + }, + props: { + weight: Property.Number({ + displayName: 'Weight (kg)', + description: 'Body weight in kilograms.', + required: true, + }), + height: Property.Number({ + displayName: 'Height (cm)', + description: 'Body height in centimetres.', + required: true, + }), + }, + outputSchema: dateConvertSchemas.bmi, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/calculate/bmi', + body: { + weight: propsValue.weight, + height: propsValue.height, + }, + }); + return { + bmi: response.bmi ?? null, + classification: response.bmiClassification ?? null, + desirable_body_weight_kg: response.dbw ?? null, + daily_calories_kcal: response.kcal ?? null, + daily_carbohydrates_g: response.nutrients?.carbohydrates ?? null, + daily_protein_g: response.nutrients?.protein ?? null, + daily_fat_g: response.nutrients?.fat ?? null, + }; + }, +}); + +type BmiResponse = { + bmi?: number; + bmiClassification?: string; + dbw?: number; + kcal?: number; + nutrients?: { + carbohydrates?: number; + protein?: number; + fat?: number; + }; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-geo-distance.ts b/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-geo-distance.ts new file mode 100644 index 000000000000..b0e71ef789d9 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/calculate/calculate-geo-distance.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const calculateGeoDistanceAction = createAction({ + auth: zeroCodeKitAuth, + name: 'calculate_geo_distance', + classification: 'READ', + displayName: 'Calculate Geo Distance', + description: 'Get the travel distance and time between two addresses.', + audience: 'both', + aiMetadata: { + description: + 'Return the route distance in kilometres and the estimated travel time between two addresses or places for a travel mode (driving, walking, bicycling, transit). This is a route distance, not a straight line. Read-only and safe to retry.', + idempotent: true, + }, + props: { + startPoint: Property.ShortText({ + displayName: 'Start Address', + description: 'Where the trip starts. An address, city or place name.', + required: true, + placeholder: 'Berlin', + }), + endPoint: Property.ShortText({ + displayName: 'End Address', + description: 'Where the trip ends. An address, city or place name.', + required: true, + placeholder: 'Munich', + }), + mode: Property.StaticDropdown({ + displayName: 'Travel Mode', + description: 'How the trip is made. This changes the route and the travel time.', + required: true, + defaultValue: 'driving', + options: { + options: [ + { label: 'Driving', value: 'driving' }, + { label: 'Walking', value: 'walking' }, + { label: 'Bicycling', value: 'bicycling' }, + { label: 'Public transit', value: 'transit' }, + ], + }, + }), + }, + outputSchema: dateConvertSchemas.geoDistance, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/calculate/geodistance-v2', + body: { + startPoint: propsValue.startPoint, + endPoint: propsValue.endPoint, + mode: propsValue.mode, + }, + }); + return { + distance_km: response.distanceInKM ?? null, + duration_hours: response.duration?.hours ?? null, + duration_minutes: response.duration?.minutes ?? null, + }; + }, +}); + +type GeoDistanceResponse = { + distanceInKM?: number; + duration?: { + hours?: number; + minutes?: number; + }; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/convert/convert-csv-to-json.ts b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-csv-to-json.ts new file mode 100644 index 000000000000..6f9077a3f7d2 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-csv-to-json.ts @@ -0,0 +1,77 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertCsvToJsonAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_csv_to_json', + classification: 'READ', + displayName: 'Convert CSV to JSON', + description: 'Turn CSV text into a list of rows you can loop over.', + audience: 'both', + aiMetadata: { + description: + 'Parse CSV text into a list of JSON objects, one per row, keyed by the header row unless the first row is data. Pass the CSV content itself, not a file or URL. Read-only and safe to retry.', + idempotent: true, + }, + props: { + csv: Property.LongText({ + displayName: 'CSV', + description: 'The CSV content to convert, including the header row.', + required: true, + }), + delimiter: Property.StaticDropdown({ + displayName: 'Delimiter', + description: 'The character that separates columns.', + required: true, + defaultValue: 'auto', + options: { + options: [ + { label: 'Detect automatically', value: 'auto' }, + { label: 'Comma (,)', value: ',' }, + { label: 'Semicolon (;)', value: ';' }, + { label: 'Tab', value: '\t' }, + { label: 'Pipe (|)', value: '|' }, + ], + }, + }), + noHeader: Property.Checkbox({ + displayName: 'First Row Is Data', + description: 'Turn on when the CSV has no header row; columns become field1, field2.', + required: false, + defaultValue: false, + }), + trim: Property.Checkbox({ + displayName: 'Trim Spaces', + description: 'Remove spaces around each value.', + required: false, + defaultValue: true, + }), + ignoreEmpty: Property.Checkbox({ + displayName: 'Skip Empty Values', + description: 'Leave empty cells out of each row instead of returning empty text.', + required: false, + defaultValue: false, + }), + }, + outputSchema: dateConvertSchemas.csvToJson, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/convert/csv/json', + body: { + csv: propsValue.csv, + delimiter: propsValue.delimiter === 'auto' ? 'auto' : [propsValue.delimiter], + noheader: propsValue.noHeader ?? false, + trim: propsValue.trim ?? true, + ignoreEmpty: propsValue.ignoreEmpty ?? false, + }, + }); + return response.json ?? []; + }, +}); + +type CsvToJsonResponse = { + json?: unknown[]; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/convert/convert-currency.ts b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-currency.ts new file mode 100644 index 000000000000..b7c40ef4f966 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-currency.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertCurrencyAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_currency', + classification: 'READ', + displayName: 'Convert Currency', + description: 'Convert an amount from one currency to another at the latest exchange rate.', + audience: 'both', + aiMetadata: { + description: + 'Convert an amount between two currencies given as ISO 4217 codes (USD, EUR, GBP) using the most recent exchange rate 0CodeKit has, and return the date of that rate. Read-only and safe to retry, though the rate can change between calls.', + idempotent: true, + }, + props: { + amount: Property.Number({ + displayName: 'Amount', + description: 'The amount to convert.', + required: true, + }), + sourceCurrency: Property.ShortText({ + displayName: 'From Currency', + description: 'The three-letter currency code of the amount, for example `USD`.', + required: true, + placeholder: 'USD', + }), + targetCurrency: Property.ShortText({ + displayName: 'To Currency', + description: 'The three-letter currency code to convert to, for example `EUR`.', + required: true, + placeholder: 'EUR', + }), + dateFormat: Property.ShortText({ + displayName: 'Rate Date Format', + description: 'How the rate date is written, using `DD`, `MM` and `YYYY` tokens.', + required: false, + placeholder: 'DD.MM.YYYY', + }), + }, + outputSchema: dateConvertSchemas.currency, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/convert/currency', + body: { + amount: propsValue.amount, + sourceCurrency: propsValue.sourceCurrency.trim().toUpperCase(), + targetCurrency: propsValue.targetCurrency.trim().toUpperCase(), + dateFormat: propsValue.dateFormat, + }, + }); + return { + converted_amount: response.convertedAmount ?? null, + converted_currency: response.currency ?? null, + original_amount: response.oldAmount ?? null, + original_currency: response.oldCurrency ?? null, + rate_date: response.dataDate ?? null, + }; + }, +}); + +type CurrencyResponse = { + oldAmount?: number; + oldCurrency?: string; + convertedAmount?: number; + currency?: string; + dataDate?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/convert/convert-ip-to-geo.ts b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-ip-to-geo.ts new file mode 100644 index 000000000000..c7dd9a33a34e --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-ip-to-geo.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertIpToGeoAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_ip_to_geo', + classification: 'READ', + displayName: 'Convert IP to Geo', + description: 'Look up the country, city, coordinates and provider of an IP address.', + audience: 'both', + aiMetadata: { + description: + 'Geolocate a public IPv4 or IPv6 address and return its country, region, city, postal code, latitude/longitude, time zone and internet provider. Private or local addresses cannot be located. Read-only and safe to retry.', + idempotent: true, + }, + props: { + ip: Property.ShortText({ + displayName: 'IP Address', + description: 'The public IP address to look up.', + required: true, + placeholder: '87.155.190.147', + }), + }, + outputSchema: dateConvertSchemas.ipToGeo, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/convert/iptogeo', + body: { ip: propsValue.ip.trim() }, + }); + return { + ip: response.query ?? null, + country: response.country ?? null, + country_code: response.countryCode ?? null, + region_code: response.region ?? null, + region_name: response.regionName ?? null, + city: response.city ?? null, + zip: response.zip ?? null, + latitude: response.lat ?? null, + longitude: response.lon ?? null, + time_zone: response.timezone ?? null, + isp: response.isp ?? null, + organization: response.org ?? null, + autonomous_system: response.as ?? null, + }; + }, +}); + +type IpToGeoResponse = { + status?: string; + country?: string; + countryCode?: string; + region?: string; + regionName?: string; + city?: string; + zip?: string; + lat?: number; + lon?: number; + timezone?: string; + isp?: string; + org?: string; + as?: string; + query?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/convert/convert-iso-to-nation.ts b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-iso-to-nation.ts new file mode 100644 index 000000000000..095ee739b4c6 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-iso-to-nation.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { nationIsoApi } from '../../common/nation-iso'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertIsoToNationAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_iso_to_nation', + classification: 'READ', + displayName: 'Convert ISO to Nation', + description: 'Get the English country name for an ISO country code.', + audience: 'both', + aiMetadata: { + description: + 'Return the English country name for an ISO country code such as DE. Use "Convert Nation to ISO" for the reverse direction. Read-only and safe to retry.', + idempotent: true, + }, + props: { + iso: Property.ShortText({ + displayName: 'ISO Country Code', + description: 'The ISO country code, for example `DE`.', + required: true, + placeholder: 'DE', + }), + }, + outputSchema: dateConvertSchemas.nationIso, + async run({ auth, propsValue }) { + return nationIsoApi.convert({ + apiKey: auth.secret_text, + body: { iso: propsValue.iso.trim() }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/convert/convert-nation-to-iso.ts b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-nation-to-iso.ts new file mode 100644 index 000000000000..da9c249ad883 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/convert/convert-nation-to-iso.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { nationIsoApi } from '../../common/nation-iso'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertNationToIsoAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_nation_to_iso', + classification: 'READ', + displayName: 'Convert Nation to ISO', + description: 'Get the ISO country code for an English country name.', + audience: 'both', + aiMetadata: { + description: + 'Return the ISO country code for a country name written in English, such as Germany. Use "Convert ISO to Nation" for the reverse direction. Read-only and safe to retry.', + idempotent: true, + }, + props: { + nation: Property.ShortText({ + displayName: 'Country Name', + description: 'The country name in English, for example `Germany`.', + required: true, + placeholder: 'Germany', + }), + }, + outputSchema: dateConvertSchemas.nationIso, + async run({ auth, propsValue }) { + return nationIsoApi.convert({ + apiKey: auth.secret_text, + body: { nation: propsValue.nation.trim() }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-date.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-date.ts new file mode 100644 index 000000000000..20e08afa2c83 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-date.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertTimezoneWithDateAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_timezone_with_date', + classification: 'READ', + displayName: 'Convert Between Timezones with Date', + description: 'Convert a date and time from one time zone to another.', + audience: 'both', + aiMetadata: { + description: + 'Convert a date and time written as text from a source time zone to a destination time zone, optionally reformatting it. Use "Convert Between Timezones with Unix Timestamp" when the input is a numeric timestamp. Read-only and safe to retry.', + idempotent: true, + }, + props: { + inputTime: Property.ShortText({ + displayName: 'Date and Time', + description: 'The date and time to convert, for example `2025-01-20 14:30`.', + required: true, + placeholder: '2025-01-20 14:30', + }), + inputTimeZone: zeroCodeKitProps.timeZone({ + displayName: 'From Time Zone', + description: 'The time zone the date and time above is in.', + required: true, + }), + destinationTimeZone: zeroCodeKitProps.timeZone({ + displayName: 'To Time Zone', + description: 'The time zone to convert to.', + required: true, + }), + formatPattern: zeroCodeKitProps.dateFormat({ + displayName: 'Output Format', + description: + 'How the converted time is written, using tokens such as `DD.MM.YYYY HH:mm`. Leave empty to use the 0CodeKit default.', + required: false, + }), + }, + outputSchema: dateConvertSchemas.switchTimeZone, + async run({ auth, propsValue }) { + return dateAndTimeApi.switchTimeZone({ + apiKey: auth.secret_text, + body: { + inputTime: propsValue.inputTime, + inputTimeZone: propsValue.inputTimeZone, + destinationTimeZone: propsValue.destinationTimeZone, + formatPattern: propsValue.formatPattern, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-unix-timestamp.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-unix-timestamp.ts new file mode 100644 index 000000000000..67e944de9f5b --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/convert-timezone-with-unix-timestamp.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const convertTimezoneWithUnixTimestampAction = createAction({ + auth: zeroCodeKitAuth, + name: 'convert_timezone_with_unix_timestamp', + classification: 'READ', + displayName: 'Convert Between Timezones with Unix Timestamp', + description: 'Show a Unix timestamp as a date and time in another time zone.', + audience: 'both', + aiMetadata: { + description: + 'Convert a Unix timestamp in seconds into a formatted date and time in the destination time zone. Use "Convert Between Timezones with Date" when the input is a date written as text. Read-only and safe to retry.', + idempotent: true, + }, + props: { + inputTime: Property.Number({ + displayName: 'Unix Timestamp', + description: 'The moment to convert, in seconds since 1 January 1970.', + required: true, + placeholder: '1737331200', + }), + inputTimeZone: zeroCodeKitProps.timeZone({ + displayName: 'From Time Zone', + description: 'The time zone of the timestamp. Unix timestamps are normally UTC.', + required: false, + }), + destinationTimeZone: zeroCodeKitProps.timeZone({ + displayName: 'To Time Zone', + description: 'The time zone to convert to.', + required: true, + }), + formatPattern: zeroCodeKitProps.dateFormat({ + displayName: 'Output Format', + description: + 'How the converted time is written, using tokens such as `DD.MM.YYYY HH:mm`. Leave empty to use the 0CodeKit default.', + required: false, + }), + }, + outputSchema: dateConvertSchemas.switchTimeZone, + async run({ auth, propsValue }) { + return dateAndTimeApi.switchTimeZone({ + apiKey: auth.secret_text, + body: { + inputTime: propsValue.inputTime, + inputTimeZone: propsValue.inputTimeZone ?? 'UTC', + destinationTimeZone: propsValue.destinationTimeZone, + formatPattern: propsValue.formatPattern, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-month.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-month.ts new file mode 100644 index 000000000000..8a1d807b7fa9 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-month.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentMonthAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_month', + classification: 'READ', + displayName: 'Current Month', + description: 'Get the days, workdays, Saturdays and Sundays of the current month.', + audience: 'both', + aiMetadata: { + description: + 'Return the number of days, first and last day, last workday, and the lists of workdays, Saturdays and Sundays for the month that is current right now. Use Specific Month and Year for any other month. Read-only and safe to retry.', + idempotent: true, + }, + props: { + outputFormat: zeroCodeKitProps.outputFormat(), + returnTimestamps: zeroCodeKitProps.returnTimestamps(), + }, + outputSchema: dateConvertSchemas.month, + async run({ auth, propsValue }) { + return dateAndTimeApi.month({ + apiKey: auth.secret_text, + body: { + outputFormat: propsValue.outputFormat, + options: dateAndTimeApi.monthOptions({ returnTimestamps: propsValue.returnTimestamps }), + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-date.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-date.ts new file mode 100644 index 000000000000..a35634f89c6a --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-date.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentWeekFromDateAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_week_from_date', + classification: 'READ', + displayName: 'Current Week from Date', + description: 'Get the calendar week number and the first and last day of the week a date falls in.', + audience: 'both', + aiMetadata: { + description: + 'Return the calendar week number of a date written as text, plus the first and last day of that week. The Date Format must describe how the date is written. Use "Current Week from Unix Timestamp" for numeric timestamps. Read-only and safe to retry.', + idempotent: true, + }, + props: { + date: Property.ShortText({ + displayName: 'Date', + description: 'The date to look up, written in the format below.', + required: true, + placeholder: '20.01.2025', + }), + dateFormat: zeroCodeKitProps.dateFormat({ + displayName: 'Date Format', + description: + 'How the date above is written, using tokens such as `DD` (day), `MM` (month) and `YYYY` (year). For example `DD.MM.YYYY` for 20.01.2025 or `YYYY-MM-DD` for 2025-01-20.', + required: true, + }), + outputFormat: zeroCodeKitProps.outputFormat(), + }, + outputSchema: dateConvertSchemas.calendarWeek, + async run({ auth, propsValue }) { + return dateAndTimeApi.calendarWeek({ + apiKey: auth.secret_text, + body: { + date: propsValue.date, + dateFormat: propsValue.dateFormat, + outputFormat: propsValue.outputFormat, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-unix-timestamp.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-unix-timestamp.ts new file mode 100644 index 000000000000..abc810fda0cf --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-from-unix-timestamp.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentWeekFromUnixTimestampAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_week_from_unix_timestamp', + classification: 'READ', + displayName: 'Current Week from Unix Timestamp', + description: 'Get the calendar week number and the first and last day of the week a Unix timestamp falls in.', + audience: 'both', + aiMetadata: { + description: + 'Return the calendar week number of a Unix timestamp in seconds, plus the first and last day of that week. Use "Current Week from Date" when the date is text. Read-only and safe to retry.', + idempotent: true, + }, + props: { + unixTimestamp: Property.Number({ + displayName: 'Unix Timestamp', + description: 'The moment to look up, in seconds since 1 January 1970.', + required: true, + placeholder: '1737331200', + }), + outputFormat: zeroCodeKitProps.outputFormat(), + }, + outputSchema: dateConvertSchemas.calendarWeek, + async run({ auth, propsValue }) { + return dateAndTimeApi.calendarWeek({ + apiKey: auth.secret_text, + body: { + unixTimestamp: propsValue.unixTimestamp, + outputFormat: propsValue.outputFormat, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number-and-year.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number-and-year.ts new file mode 100644 index 000000000000..6bbe0f0398e9 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number-and-year.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { dateInput } from '../../common/date-input'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentWeekWithWeekNumberAndYearAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_week_with_week_number_and_year', + classification: 'READ', + displayName: 'Current Week with Week Number and Year', + description: 'Get the first and last day of a calendar week in a chosen year.', + audience: 'both', + aiMetadata: { + description: + 'Return the first and last day of a calendar week number in a given year. Use "Current Week only with Week Number" when the year is the current one. Read-only and safe to retry.', + idempotent: true, + }, + props: { + weekNumber: Property.Number({ + displayName: 'Week Number', + description: 'The calendar week number, from 1 to 53.', + required: true, + }), + year: Property.Number({ + displayName: 'Year', + description: 'The four-digit year, for example 2025.', + required: true, + }), + outputFormat: zeroCodeKitProps.outputFormat(), + }, + outputSchema: dateConvertSchemas.calendarWeek, + async run({ auth, propsValue }) { + return dateAndTimeApi.calendarWeek({ + apiKey: auth.secret_text, + body: { + date: dateInput.isoWeekMonday({ weekNumber: propsValue.weekNumber, year: propsValue.year }), + dateFormat: 'DD.MM.YYYY', + outputFormat: propsValue.outputFormat, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number.ts new file mode 100644 index 000000000000..64ddfc7a6663 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week-with-week-number.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { dateInput } from '../../common/date-input'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentWeekWithWeekNumberAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_week_with_week_number', + classification: 'READ', + displayName: 'Current Week only with Week Number', + description: 'Get the first and last day of a calendar week in the current year.', + audience: 'both', + aiMetadata: { + description: + 'Return the first and last day of a calendar week number in the current year. Use "Current Week with Week Number and Year" to pick a different year. Read-only and safe to retry.', + idempotent: true, + }, + props: { + weekNumber: Property.Number({ + displayName: 'Week Number', + description: 'The calendar week number, from 1 to 53.', + required: true, + }), + outputFormat: zeroCodeKitProps.outputFormat(), + }, + outputSchema: dateConvertSchemas.calendarWeek, + async run({ auth, propsValue }) { + return dateAndTimeApi.calendarWeek({ + apiKey: auth.secret_text, + body: { + date: dateInput.isoWeekMonday({ weekNumber: propsValue.weekNumber, year: new Date().getUTCFullYear() }), + dateFormat: 'DD.MM.YYYY', + outputFormat: propsValue.outputFormat, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week.ts new file mode 100644 index 000000000000..35e119125255 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/current-week.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const currentWeekAction = createAction({ + auth: zeroCodeKitAuth, + name: 'current_week', + classification: 'READ', + displayName: 'Current Week', + description: 'Get the calendar week number and the first and last day of the current week.', + audience: 'both', + aiMetadata: { + description: + 'Return the calendar week number of today plus the first and last day of this week. Use the "Current Week from Date" or "from Unix Timestamp" actions for another day. Read-only and safe to retry.', + idempotent: true, + }, + props: { + outputFormat: zeroCodeKitProps.outputFormat(), + }, + outputSchema: dateConvertSchemas.calendarWeek, + async run({ auth, propsValue }) { + return dateAndTimeApi.calendarWeek({ + apiKey: auth.secret_text, + body: { + outputFormat: propsValue.outputFormat, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/get-holidays.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/get-holidays.ts new file mode 100644 index 000000000000..a299cd206720 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/get-holidays.ts @@ -0,0 +1,68 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const getHolidaysAction = createAction({ + auth: zeroCodeKitAuth, + name: 'get_holidays', + classification: 'SEARCH', + displayName: 'Holidays in Specific Country and Year', + description: 'List the public holidays of a country, and optionally a state, for a year.', + audience: 'both', + aiMetadata: { + description: + 'List public holidays for a country (ISO 3166-1 alpha-2 code) and year, optionally narrowed to a state or region. Returns one row per holiday. Read-only and safe to retry.', + idempotent: true, + }, + props: { + countryCode: Property.ShortText({ + displayName: 'Country Code', + description: 'The two-letter ISO country code of the country.', + required: true, + placeholder: 'DE', + }), + year: Property.Number({ + displayName: 'Year', + description: 'The four-digit year, for example 2025.', + required: true, + }), + state: Property.ShortText({ + displayName: 'State', + description: 'A state or region code to add regional holidays. Empty: national only.', + required: false, + placeholder: 'BY', + }), + }, + outputSchema: dateConvertSchemas.holidays, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/dateandtime/holidays', + body: { + year: propsValue.year, + countryCode: propsValue.countryCode.trim().toUpperCase(), + state: propsValue.state?.trim(), + }, + }); + return (response.holidays ?? []).map((holiday) => ({ + name: holiday.name ?? null, + date: holiday.date ?? null, + type: holiday.type ?? null, + start: holiday.start ?? null, + end: holiday.end ?? null, + rule: holiday.rule ?? null, + })); + }, +}); + +type HolidaysResponse = { + holidays?: { + date?: string; + start?: string; + end?: string; + name?: string; + type?: string; + rule?: string; + }[]; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend-with-format.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend-with-format.ts new file mode 100644 index 000000000000..d45cf5065318 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend-with-format.ts @@ -0,0 +1,49 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { dateInput } from '../../common/date-input'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const isWeekendWithFormatAction = createAction({ + auth: zeroCodeKitAuth, + name: 'is_weekend_with_format', + classification: 'READ', + displayName: 'Is Given Date is Weekend, with Format', + description: 'Check whether a date written in a custom format falls on a weekend, and get its weekday.', + audience: 'both', + aiMetadata: { + description: + 'Check whether a date is on a weekend and return its weekday name and number, parsing the date with the given Date Format (for example DD.MM.YYYY). Use "Is Given Date is Weekend" for standard ISO dates. Read-only and safe to retry.', + idempotent: true, + }, + props: { + date: Property.ShortText({ + displayName: 'Date', + description: 'The date to check, written in the format below.', + required: true, + placeholder: '20.01.2025', + }), + dateFormat: zeroCodeKitProps.dateFormat({ + displayName: 'Date Format', + description: + 'How the date above is written, using `YYYY` or `YY` (year), `MM` or `M` (month) and `DD` or `D` (day), with any separators. For example `DD.MM.YYYY` for 20.01.2025.', + required: true, + }), + timeZone: zeroCodeKitProps.timeZone({ + displayName: 'Time Zone', + description: 'The time zone the date belongs to. Leave empty to use the 0CodeKit default.', + required: false, + }), + }, + outputSchema: dateConvertSchemas.isWeekend, + async run({ auth, propsValue }) { + return dateAndTimeApi.isWeekend({ + apiKey: auth.secret_text, + body: { + date: dateInput.toIsoDate({ date: propsValue.date, format: propsValue.dateFormat ?? '' }), + timeZone: propsValue.timeZone, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend.ts new file mode 100644 index 000000000000..9350dc23cfb7 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/is-weekend.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const isWeekendAction = createAction({ + auth: zeroCodeKitAuth, + name: 'is_weekend', + classification: 'READ', + displayName: 'Is Given Date is Weekend', + description: 'Check whether a date falls on a Saturday or Sunday, and get its weekday.', + audience: 'both', + aiMetadata: { + description: + 'Check whether a date is on a weekend and return its weekday name and number. Pass the date in a standard form such as 2025-01-20; use "Is Given Date is Weekend, with Format" when the date is written in a custom format. Read-only and safe to retry.', + idempotent: true, + }, + props: { + date: Property.ShortText({ + displayName: 'Date', + description: 'The date to check, for example 2025-01-20.', + required: true, + placeholder: '2025-01-20', + }), + timeZone: zeroCodeKitProps.timeZone({ + displayName: 'Time Zone', + description: 'The time zone the date belongs to. Leave empty to use the 0CodeKit default.', + required: false, + }), + }, + outputSchema: dateConvertSchemas.isWeekend, + async run({ auth, propsValue }) { + return dateAndTimeApi.isWeekend({ + apiKey: auth.secret_text, + body: { + date: propsValue.date, + timeZone: propsValue.timeZone, + }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/date-and-time/specific-month-and-year.ts b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/specific-month-and-year.ts new file mode 100644 index 000000000000..ebaf0a8e1368 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/date-and-time/specific-month-and-year.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { dateAndTimeApi } from '../../common/date-and-time'; +import { zeroCodeKitProps } from '../../common/props'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const specificMonthAndYearAction = createAction({ + auth: zeroCodeKitAuth, + name: 'specific_month_and_year', + classification: 'READ', + displayName: 'Specific Month and Year', + description: 'Get the days, workdays, Saturdays and Sundays of a chosen month.', + audience: 'both', + aiMetadata: { + description: + 'Return the number of days, first and last day, last workday, and the lists of workdays, Saturdays and Sundays for a given month and year. Use Current Month when you only need the month that is current now. Read-only and safe to retry.', + idempotent: true, + }, + props: { + month: Property.StaticDropdown({ + displayName: 'Month', + description: 'The month to describe.', + required: true, + options: { + options: monthOptions(), + }, + }), + year: Property.Number({ + displayName: 'Year', + description: 'The four-digit year, for example 2025.', + required: true, + }), + outputFormat: zeroCodeKitProps.outputFormat(), + returnTimestamps: zeroCodeKitProps.returnTimestamps(), + }, + outputSchema: dateConvertSchemas.month, + async run({ auth, propsValue }) { + return dateAndTimeApi.month({ + apiKey: auth.secret_text, + body: { + month: propsValue.month, + year: propsValue.year, + outputFormat: propsValue.outputFormat, + options: dateAndTimeApi.monthOptions({ returnTimestamps: propsValue.returnTimestamps }), + }, + }); + }, +}); + +function monthOptions() { + return [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ].map((name, index) => ({ label: name, value: index + 1 })); +} diff --git a/packages/pieces/community/0codekit/src/lib/actions/generate/get-a-number.ts b/packages/pieces/community/0codekit/src/lib/actions/generate/get-a-number.ts new file mode 100644 index 000000000000..66a99425ceb5 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/generate/get-a-number.ts @@ -0,0 +1,70 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const getANumberAction = createAction({ + auth: zeroCodeKitAuth, + name: 'get_a_number', + classification: 'READ', + displayName: 'Get a Number', + description: 'Generate a random number between a minimum and a maximum.', + audience: 'both', + aiMetadata: { + description: + 'Generate a random whole or decimal number between a minimum and a maximum, with a chosen number of decimal places for decimals. Each call returns a different value and has no side effects.', + idempotent: true, + }, + props: { + min: Property.Number({ + displayName: 'Minimum', + description: 'The smallest number that can be returned.', + required: true, + defaultValue: 1, + }), + max: Property.Number({ + displayName: 'Maximum', + description: 'The largest number that can be returned.', + required: true, + defaultValue: 100, + }), + type: Property.StaticDropdown({ + displayName: 'Number Type', + description: 'Whether to return a whole number or a decimal number.', + required: true, + defaultValue: 'integer', + options: { + options: [ + { label: 'Whole number', value: 'integer' }, + { label: 'Decimal number', value: 'decimal' }, + ], + }, + }), + round: Property.Number({ + displayName: 'Decimal Places', + description: 'Digits after the decimal point. Only used for decimal numbers.', + required: false, + defaultValue: 2, + }), + }, + outputSchema: dateConvertSchemas.randomNumber, + async run({ auth, propsValue }) { + if (propsValue.min > propsValue.max) { + throw new Error('Minimum must be less than or equal to Maximum.'); + } + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/generate/number', + body: { + range: [propsValue.min, propsValue.max], + type: propsValue.type, + round: propsValue.type === 'integer' ? undefined : propsValue.round, + }, + }); + return { number: response.randomNumber ?? null }; + }, +}); + +type RandomNumberResponse = { + randomNumber?: number; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-city.ts b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-city.ts new file mode 100644 index 000000000000..5988717d711d --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-city.ts @@ -0,0 +1,35 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const getRandomCityAction = createAction({ + auth: zeroCodeKitAuth, + name: 'get_random_city', + classification: 'READ', + displayName: 'Get a Random City', + description: 'Generate a random, fictional city and country name for test data.', + audience: 'both', + aiMetadata: { + description: + 'Generate a random fictional city name with a country, for mock or test data. Each call returns a different value and has no side effects.', + idempotent: true, + }, + props: {}, + outputSchema: dateConvertSchemas.randomCity, + async run({ auth }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/generate/city', + }); + return { + city: response.city ?? null, + country: response.country ?? null, + }; + }, +}); + +type RandomCityResponse = { + city?: string; + country?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name-with-gender.ts b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name-with-gender.ts new file mode 100644 index 000000000000..f41be56ba92a --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name-with-gender.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { randomNameApi } from '../../common/random-name'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const getRandomNameWithGenderAction = createAction({ + auth: zeroCodeKitAuth, + name: 'get_random_name_with_gender', + classification: 'READ', + displayName: 'Get Random Name with Gender', + description: 'Generate a random male or female person name for test data.', + audience: 'both', + aiMetadata: { + description: + 'Generate a random first, middle and last name of the chosen gender for mock or test data. Use "Get Random Name" when gender does not matter. Each call returns a different value and has no side effects.', + idempotent: true, + }, + props: { + gender: Property.StaticDropdown({ + displayName: 'Gender', + description: 'The gender of the generated name.', + required: true, + defaultValue: 'female', + options: { + options: [ + { label: 'Female', value: 'female' }, + { label: 'Male', value: 'male' }, + ], + }, + }), + }, + outputSchema: dateConvertSchemas.randomName, + async run({ auth, propsValue }) { + return randomNameApi.generate({ + apiKey: auth.secret_text, + body: { gender: propsValue.gender }, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name.ts b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name.ts new file mode 100644 index 000000000000..8f404ebb6500 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/generate/get-random-name.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { randomNameApi } from '../../common/random-name'; +import { dateConvertSchemas } from '../../common/output-schemas/date-convert'; + +export const getRandomNameAction = createAction({ + auth: zeroCodeKitAuth, + name: 'get_random_name', + classification: 'READ', + displayName: 'Get Random Name', + description: 'Generate a random person name for test data.', + audience: 'both', + aiMetadata: { + description: + 'Generate a random first, middle and last name for mock or test data. Use "Get Random Name with Gender" to force a male or female name. Each call returns a different value and has no side effects.', + idempotent: true, + }, + props: {}, + outputSchema: dateConvertSchemas.randomName, + async run({ auth }) { + return randomNameApi.generate({ + apiKey: auth.secret_text, + body: {}, + }); + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/actions/text/detect-gender.ts b/packages/pieces/community/0codekit/src/lib/actions/text/detect-gender.ts new file mode 100644 index 000000000000..f27c10eb9a1d --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/text/detect-gender.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const detectGenderAction = createAction({ + auth: zeroCodeKitAuth, + name: 'detect_gender', + classification: 'READ', + displayName: 'Detect Gender', + description: 'Guess whether a first name is male, female or unisex.', + audience: 'both', + aiMetadata: { + description: + 'Guess the gender (male, female or unisex) most associated with a first name. Accepts a first name or a full name; when both are given the first name wins. Read-only and safe to retry.', + idempotent: true, + }, + props: { + firstName: Property.ShortText({ + displayName: 'First Name', + description: 'The first name to check. Fill this or Full Name.', + required: false, + placeholder: 'Jane', + }), + fullName: Property.ShortText({ + displayName: 'Full Name', + description: 'Used when First Name is empty; its first word is the first name.', + required: false, + placeholder: 'Jane Doe', + }), + }, + outputSchema: businessAiOutputSchemas.detectGender, + async run({ auth, propsValue }) { + const firstName = propsValue.firstName?.trim(); + const fullName = propsValue.fullName?.trim(); + if (!firstName && !fullName) { + throw new Error('Fill in First Name or Full Name.'); + } + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/operator/gender', + body: { + firstname: firstName, + fullname: fullName, + }, + }); + return { + gender: response.detectedGender ?? null, + first_name: response.firstname ?? null, + last_name: response.lastname ?? null, + }; + }, +}); + +type GenderResponse = { + firstname?: string; + lastname?: string; + detectedGender?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/text/split-name.ts b/packages/pieces/community/0codekit/src/lib/actions/text/split-name.ts new file mode 100644 index 000000000000..e74511eabaa7 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/text/split-name.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const splitNameAction = createAction({ + auth: zeroCodeKitAuth, + name: 'split_name', + classification: 'READ', + displayName: 'Split Name', + description: 'Split a full name into first name and last name.', + audience: 'both', + aiMetadata: { + description: + 'Split a full name into first name(s) and last name. Turn on Last Name First for names written family name first, such as many Chinese names. Read-only and safe to retry.', + idempotent: true, + }, + props: { + name: Property.ShortText({ + displayName: 'Full Name', + description: 'The full name to split.', + required: true, + placeholder: 'Jane Mary Doe', + }), + reversed: Property.Checkbox({ + displayName: 'Last Name First', + description: 'Turn on when the name is written with the last name first.', + required: false, + defaultValue: false, + }), + }, + outputSchema: businessAiOutputSchemas.splitName, + async run({ auth, propsValue }) { + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/operator/splitname', + body: { + name: propsValue.name, + reversed: propsValue.reversed ?? false, + }, + }); + return { + first_name: response.firstName ?? null, + last_name: response.lastName ?? null, + }; + }, +}); + +type SplitNameResponse = { + firstName?: string; + lastName?: string; +}; diff --git a/packages/pieces/community/0codekit/src/lib/actions/text/text-contains.ts b/packages/pieces/community/0codekit/src/lib/actions/text/text-contains.ts new file mode 100644 index 000000000000..de7d563aec90 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/actions/text/text-contains.ts @@ -0,0 +1,88 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { zeroCodeKitAuth } from '../../auth'; +import { zeroCodeKitApi } from '../../common/client'; +import { businessAiOutputSchemas } from '../../common/output-schemas/business-ai'; + +export const textContainsAction = createAction({ + auth: zeroCodeKitAuth, + name: 'text_contains', + classification: 'READ', + displayName: 'Text Contains', + description: 'Check whether a text contains one or more keywords, and where.', + audience: 'both', + aiMetadata: { + description: + 'Search a text for one or more keywords and report, per keyword, whether it was found and at which character positions, plus whether any or all keywords matched. Optionally case-sensitive or whole-word only. Read-only and safe to retry.', + idempotent: true, + }, + props: { + text: Property.LongText({ + displayName: 'Text', + description: 'The text to search in.', + required: true, + }), + keywords: Property.Array({ + displayName: 'Keywords', + description: 'The words or phrases to look for.', + required: true, + }), + caseSensitive: Property.Checkbox({ + displayName: 'Case Sensitive', + description: 'Turn on to treat "Apple" and "apple" as different words.', + required: false, + defaultValue: false, + }), + onlyCompleteWords: Property.Checkbox({ + displayName: 'Whole Words Only', + description: 'Turn on so that "cat" does not match inside "category".', + required: false, + defaultValue: false, + }), + }, + outputSchema: businessAiOutputSchemas.textContains, + async run({ auth, propsValue }) { + const keywords = normalizeKeywords(propsValue.keywords); + if (keywords.length === 0) { + throw new Error('Add at least one keyword to search for.'); + } + const response = await zeroCodeKitApi.post({ + apiKey: auth.secret_text, + path: '/text/contains', + body: { + text: propsValue.text, + keywordList: keywords, + options: { + caseSensitive: propsValue.caseSensitive ?? false, + onlyCompleteWords: propsValue.onlyCompleteWords ?? false, + }, + }, + }); + const findings = (response.findings ?? []).map((finding) => ({ + keyword: finding.keyword ?? null, + contains: finding.contains ?? false, + match_count: finding.foundPositions?.length ?? 0, + found_positions: (finding.foundPositions ?? []).join(', '), + })); + return { + contains_any: findings.some((finding) => finding.contains), + contains_all: findings.length > 0 && findings.every((finding) => finding.contains), + findings, + }; + }, +}); + +function normalizeKeywords(keywords: unknown[]): string[] { + return keywords + .flat() + .filter((keyword): keyword is string | number => typeof keyword === 'string' || typeof keyword === 'number') + .map((keyword) => String(keyword).trim()) + .filter((keyword) => keyword.length > 0); +} + +type TextContainsResponse = { + findings?: { + keyword?: string; + contains?: boolean; + foundPositions?: number[]; + }[]; +}; diff --git a/packages/pieces/community/0codekit/src/lib/auth.ts b/packages/pieces/community/0codekit/src/lib/auth.ts new file mode 100644 index 000000000000..070c9927ebaf --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/auth.ts @@ -0,0 +1,35 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; +import { zeroCodeKitApi } from './common/client'; + +export const zeroCodeKitAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: `To get your API key: +1. Log in to your [0CodeKit](https://0codekit.com) account. +2. Open the **Account** tab. Your API key is shown there. +3. Copy the key and paste it here.`, + required: true, + validate: async ({ auth }) => { + try { + await zeroCodeKitApi.request({ + apiKey: auth, + path: '/1saas/auth', + }); + return { valid: true }; + } catch (error) { + const status = zeroCodeKitApi.statusOf(error); + if (status === 401 || status === 403) { + return { + valid: false, + error: '0CodeKit rejected this API key. Copy it again from your 0CodeKit settings.', + }; + } + return { + valid: false, + error: `Could not reach 0CodeKit to check this key: ${zeroCodeKitApi.describe({ + error, + fallback: 'unknown error', + })}`, + }; + } + }, +}); diff --git a/packages/pieces/community/0codekit/src/lib/common/client.ts b/packages/pieces/community/0codekit/src/lib/common/client.ts new file mode 100644 index 000000000000..d7c2e94c08bf --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/client.ts @@ -0,0 +1,121 @@ +import { httpClient, HttpError, HttpMethod } from '@activepieces/pieces-common'; + +export const zeroCodeKitApi = { + request, + post, + statusOf, + describe, + timeoutFor, + assertProviderUrl, +}; + +export const ZEROCODEKIT_BASE_URL = 'https://v2.1saas.co'; + +export const ZEROCODEKIT_TIMEOUT_MS = { + lookup: 30_000, + slow: 60_000, + file: 120_000, +}; + +async function request({ apiKey, path, body, timeoutMs }: ZeroCodeKitRequest): Promise { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${ZEROCODEKIT_BASE_URL}${path}`, + headers: { + auth: apiKey, + }, + body: compact(body ?? {}), + timeout: timeoutMs ?? timeoutFor(path), + }); + return response.body; +} + +async function post(params: ZeroCodeKitRequest): Promise { + try { + return await request(params); + } catch (error) { + throw new Error(describe({ error, fallback: 'The request to 0CodeKit failed.' })); + } +} + +function assertProviderUrl(url: unknown): void { + if (typeof url !== 'string' || !(url.startsWith('http://') || url.startsWith('https://'))) { + return; + } + const origin = parseOrigin(url); + if (origin !== new URL(ZEROCODEKIT_BASE_URL).origin) { + throw new Error( + `Custom API Call only sends your 0CodeKit API key to ${ZEROCODEKIT_BASE_URL}. Use a path such as /1saas/auth, or a full URL on that host.`, + ); + } +} + +function parseOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +function timeoutFor(path: string): number { + if (FILE_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return ZEROCODEKIT_TIMEOUT_MS.file; + } + if (SLOW_PATH_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return ZEROCODEKIT_TIMEOUT_MS.slow; + } + return ZEROCODEKIT_TIMEOUT_MS.lookup; +} + +function statusOf(error: unknown): number | undefined { + if (error instanceof HttpError) { + return error.response.status; + } + return undefined; +} + +function describe({ error, fallback }: { error: unknown; fallback: string }): string { + if (isTimeout(error)) { + return `0CodeKit did not answer in time. ${fallback}`; + } + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : fallback; + } + const status = error.response.status; + const body = error.response.body; + const message = isRecord(body) ? asString(body['errorMessage']) ?? asString(body['message']) : undefined; + const code = isRecord(body) ? asString(body['code']) : undefined; + const detail = message ?? fallback; + const suffix = code === undefined ? '' : ` (${code})`; + return `0CodeKit returned ${status}: ${detail}${suffix}`; +} + +function isTimeout(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); +} + +function compact(body: Record): Record { + return Object.fromEntries( + Object.entries(body).filter(([, value]) => value !== undefined && value !== null && value !== ''), + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +const FILE_PATH_PREFIXES = ['/pdf/', '/image/', '/generate/qrcode/', '/storage/temp', '/storage/perm/add', '/storage/perm/get']; + +const SLOW_PATH_PREFIXES = ['/ai/', '/operator/urlexpander', '/business/verify/', '/business/validate/email', '/convert/csv/']; + +export type ZeroCodeKitRequest = { + apiKey: string; + path: string; + body?: Record; + timeoutMs?: number; +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/date-and-time.ts b/packages/pieces/community/0codekit/src/lib/common/date-and-time.ts new file mode 100644 index 000000000000..8ac27dcee97f --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/date-and-time.ts @@ -0,0 +1,137 @@ +import { zeroCodeKitApi } from './client'; + +export const dateAndTimeApi = { + calendarWeek, + month, + isWeekend, + switchTimeZone, + monthOptions, +}; + +async function calendarWeek({ apiKey, body }: DateAndTimeCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/dateandtime/calendarweek', + body, + }); + return { + week_number: response.weekNumber ?? null, + working_date: response.workingDate ?? null, + first_day_of_week: response.firstDayOfWeek ?? null, + last_day_of_week: response.lastDayOfWeek ?? null, + }; +} + +async function month({ apiKey, body }: DateAndTimeCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/dateandtime/month', + body, + }); + return { + days_in_month: response.daysInMonth ?? null, + first_day_of_month: response.firstDayOfMonth ?? null, + last_day_of_month: response.lastDayOfMonth ?? null, + last_workday_of_month: response.lastWorkdayOfMonth ?? null, + workday_count: response.workdays?.length ?? 0, + workdays: response.workdays ?? [], + workdays_reversed: response.workdaysReversed ?? [], + saturdays: response.saturdays ?? [], + sundays: response.sundays ?? [], + }; +} + +async function isWeekend({ apiKey, body }: DateAndTimeCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/dateandtime/isweekend', + body, + }); + return { + is_weekend: response.isWeekend ?? null, + weekday: response.weekDay ?? null, + day_number: response.dayNumber ?? null, + }; +} + +async function switchTimeZone({ apiKey, body }: DateAndTimeCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/dateandtime/switchtimezone', + body, + }); + return { + converted_time: response.convertedTime ?? null, + time_zone: response.timeZone ?? null, + }; +} + +function monthOptions({ returnTimestamps }: { returnTimestamps: boolean | undefined }) { + return returnTimestamps === true ? { timeStamp: true } : undefined; +} + +type DateAndTimeCall = { + apiKey: string; + body: Record; +}; + +type DateValue = string | number; + +type CalendarWeekResponse = { + workingDate?: string; + weekNumber?: number; + firstDayOfWeek?: string; + lastDayOfWeek?: string; +}; + +type MonthResponse = { + daysInMonth?: number; + firstDayOfMonth?: DateValue; + lastDayOfMonth?: DateValue; + lastWorkdayOfMonth?: DateValue; + workdays?: DateValue[]; + workdaysReversed?: DateValue[]; + saturdays?: DateValue[]; + sundays?: DateValue[]; +}; + +type IsWeekendResponse = { + dayNumber?: number; + weekDay?: string; + isWeekend?: boolean; +}; + +type SwitchTimeZoneResponse = { + convertedTime?: string; + timeZone?: string; +}; + +export type CalendarWeekOutput = { + week_number: number | null; + working_date: string | null; + first_day_of_week: string | null; + last_day_of_week: string | null; +}; + +export type MonthOutput = { + days_in_month: number | null; + first_day_of_month: DateValue | null; + last_day_of_month: DateValue | null; + last_workday_of_month: DateValue | null; + workday_count: number; + workdays: DateValue[]; + workdays_reversed: DateValue[]; + saturdays: DateValue[]; + sundays: DateValue[]; +}; + +export type IsWeekendOutput = { + is_weekend: boolean | null; + weekday: string | null; + day_number: number | null; +}; + +export type SwitchTimeZoneOutput = { + converted_time: string | null; + time_zone: string | null; +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/date-input.ts b/packages/pieces/community/0codekit/src/lib/common/date-input.ts new file mode 100644 index 000000000000..9bd0ec89faed --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/date-input.ts @@ -0,0 +1,88 @@ +export const dateInput = { + toIsoDate, + isoWeekMonday, +}; + +function toIsoDate({ date, format }: { date: string; format: string }): string { + const tokens = tokenize(format.trim()); + assertEachPartOnce({ tokens, format }); + const pattern = tokens.map((token) => TOKEN_PATTERNS[token] ?? escapeRegExp(token)).join(''); + const match = new RegExp(`^${pattern}$`).exec(date.trim()); + if (match === null) { + throw new Error(`The date "${date}" does not match the format "${format}".`); + } + const groups = tokens.filter((token) => token in TOKEN_PATTERNS); + const values = Object.fromEntries(groups.map((token, index) => [token, Number(match[index + 1])])); + const year = values['YYYY'] ?? (values['YY'] === undefined ? undefined : 2000 + values['YY']); + const month = values['MM'] ?? values['M']; + const day = values['DD'] ?? values['D']; + if (year === undefined || month === undefined || day === undefined) { + throw new Error(`The format "${format}" must include a year (YYYY or YY), a month (MM or M) and a day (DD or D).`); + } + return formatIso({ year, month, day }); +} + +function isoWeekMonday({ weekNumber, year }: { weekNumber: number; year: number }): string { + if (!Number.isInteger(weekNumber) || weekNumber < 1 || weekNumber > weeksInIsoYear(year)) { + throw new Error(`Week ${weekNumber} does not exist in ${year}. Use a week number from 1 to ${weeksInIsoYear(year)}.`); + } + const monday = new Date(firstIsoMonday(year) + (weekNumber - 1) * WEEK_MS); + const day = String(monday.getUTCDate()).padStart(2, '0'); + const month = String(monday.getUTCMonth() + 1).padStart(2, '0'); + return `${day}.${month}.${monday.getUTCFullYear()}`; +} + +function assertEachPartOnce({ tokens, format }: { tokens: string[]; format: string }): void { + const parts = tokens.map((token) => DATE_PART_OF[token]).filter((part) => part !== undefined); + if (new Set(parts).size !== parts.length) { + throw new Error(`The format "${format}" uses the year, month or day more than once. Include each of them exactly once.`); + } +} + +function tokenize(format: string): string[] { + return format.match(/YYYY|YY|MM|M|DD|D|[^YMD]+/g) ?? []; +} + +function formatIso({ year, month, day }: { year: number; month: number; day: number }): string { + const candidate = new Date(Date.UTC(year, month - 1, day)); + if (candidate.getUTCFullYear() !== year || candidate.getUTCMonth() !== month - 1 || candidate.getUTCDate() !== day) { + throw new Error(`${year}-${month}-${day} is not a real calendar date.`); + } + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +function firstIsoMonday(year: number): number { + const januaryFourth = Date.UTC(year, 0, 4); + const weekday = (new Date(januaryFourth).getUTCDay() + 6) % 7; + return januaryFourth - weekday * DAY_MS; +} + +function weeksInIsoYear(year: number): number { + return Math.round((firstIsoMonday(year + 1) - firstIsoMonday(year)) / WEEK_MS); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +const WEEK_MS = 7 * DAY_MS; + +const TOKEN_PATTERNS: Record = { + YYYY: '(\\d{4})', + YY: '(\\d{2})', + MM: '(\\d{2})', + M: '(\\d{1,2})', + DD: '(\\d{2})', + D: '(\\d{1,2})', +}; + +const DATE_PART_OF: Record = { + YYYY: 'year', + YY: 'year', + MM: 'month', + M: 'month', + DD: 'day', + D: 'day', +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/nation-iso.ts b/packages/pieces/community/0codekit/src/lib/common/nation-iso.ts new file mode 100644 index 000000000000..2f913da28c54 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/nation-iso.ts @@ -0,0 +1,32 @@ +import { zeroCodeKitApi } from './client'; + +export const nationIsoApi = { + convert, +}; + +async function convert({ apiKey, body }: NationIsoCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/convert/nationiso', + body, + }); + return { + iso_code: response.iso ?? null, + nation: response.nation ?? null, + }; +} + +type NationIsoCall = { + apiKey: string; + body: Record; +}; + +type NationIsoResponse = { + iso?: string; + nation?: string; +}; + +export type NationIsoOutput = { + iso_code: string | null; + nation: string | null; +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/output-schemas/business-ai.ts b/packages/pieces/community/0codekit/src/lib/common/output-schemas/business-ai.ts new file mode 100644 index 000000000000..fa44f0958cf8 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/output-schemas/business-ai.ts @@ -0,0 +1,78 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const businessAiOutputSchemas = { + validateBic: { + fields: [{ key: 'valid', label: 'Valid', format: 'boolean' }], + }, + validateIban: { + fields: [{ key: 'valid', label: 'Valid', format: 'boolean' }], + }, + validateEmail: { + fields: [ + { key: 'valid', label: 'Valid', format: 'boolean' }, + { key: 'corrected_email', label: 'Corrected Email', format: 'email', description: 'The address with any fixable typo corrected.' }, + ], + }, + validateVatId: { fields: vatValidationFields() }, + validateVatWithCountryCode: { fields: vatValidationFields() }, + lookupVatRates: { + fields: [ + { key: 'country', label: 'Country' }, + { key: 'vat_name', label: 'VAT Name' }, + { key: 'vat_abbreviation', label: 'VAT Abbreviation' }, + { key: 'standard_rate', label: 'Standard Rate (%)', format: 'number' }, + { key: 'reduced_rate', label: 'Reduced Rate (%)', format: 'number', description: 'Null when the country has no reduced rate.' }, + { key: 'reduced_rate_alternative', label: 'Alternative Reduced Rate (%)', format: 'number', description: 'Null when the country has no second reduced rate.' }, + { key: 'super_reduced_rate', label: 'Super Reduced Rate (%)', format: 'number', description: 'Null when the country has no super-reduced rate.' }, + { key: 'parking_rate', label: 'Parking Rate (%)', format: 'number', description: 'Null when the country has no parking rate.' }, + ], + }, + verifyDomain: { + fields: [ + { key: 'is_valid', label: 'Is Valid', format: 'boolean' }, + { key: 'main_domain', label: 'Main Domain', description: 'The domain without subdomains, for example example.com.' }, + ], + }, + detectGender: { + fields: [ + { key: 'gender', label: 'Gender', description: 'male, female or unisex.' }, + { key: 'first_name', label: 'First Name' }, + { key: 'last_name', label: 'Last Name' }, + ], + }, + splitName: { + fields: [ + { key: 'first_name', label: 'First Name' }, + { key: 'last_name', label: 'Last Name' }, + ], + }, + textContains: { + fields: [ + { key: 'contains_any', label: 'Contains Any Keyword', format: 'boolean' }, + { key: 'contains_all', label: 'Contains All Keywords', format: 'boolean' }, + { + key: 'findings', + label: 'Findings', + labelKey: 'keyword', + description: 'One entry per keyword searched.', + listItems: [ + { key: 'keyword', label: 'Keyword' }, + { key: 'contains', label: 'Contains', format: 'boolean' }, + { key: 'match_count', label: 'Match Count', format: 'number' }, + { key: 'found_positions', label: 'Found Positions', description: 'Comma-separated character positions of each match.' }, + ], + }, + ], + }, +} satisfies Record; + +function vatValidationFields(): OutputSchema['fields'] { + return [ + { key: 'valid', label: 'Valid', format: 'boolean', description: 'True when VIES confirms the VAT number is registered and active.' }, + { key: 'country_code', label: 'Country Code' }, + { key: 'vat_number', label: 'VAT Number', description: 'The VAT number without its country prefix.' }, + { key: 'company_name', label: 'Company Name', description: 'Registered company name, or null when the member state does not share it.' }, + { key: 'company_address', label: 'Company Address', description: 'Registered company address, or null when the member state does not share it.' }, + { key: 'request_date', label: 'Request Date', format: 'date', description: 'The date VIES answered the check.' }, + ]; +} diff --git a/packages/pieces/community/0codekit/src/lib/common/output-schemas/date-convert.ts b/packages/pieces/community/0codekit/src/lib/common/output-schemas/date-convert.ts new file mode 100644 index 000000000000..1448ad67430d --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/output-schemas/date-convert.ts @@ -0,0 +1,184 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const dateConvertSchemas = { + calendarWeek: { fields: calendarWeekFields() }, + month: { fields: monthFields() }, + isWeekend: { fields: isWeekendFields() }, + switchTimeZone: { fields: switchTimeZoneFields() }, + holidays: { + itemLabel: '{name}', + fields: [ + { + key: 'holidays', + label: 'Holidays', + value: '', + listItems: [ + { key: 'name', label: 'Name' }, + { key: 'date', label: 'Date', format: 'date' }, + { key: 'type', label: 'Type', description: 'The holiday type, for example public.' }, + { key: 'start', label: 'Start', format: 'datetime' }, + { key: 'end', label: 'End', format: 'datetime' }, + { key: 'rule', label: 'Rule', description: 'The rule that defines when the holiday falls.' }, + ], + }, + ], + }, + bmi: { + fields: [ + { key: 'bmi', label: 'BMI', format: 'number' }, + { key: 'classification', label: 'Classification' }, + { key: 'desirable_body_weight_kg', label: 'Desirable Body Weight (kg)', format: 'number' }, + { key: 'daily_calories_kcal', label: 'Daily Calories (kcal)', format: 'number' }, + { key: 'daily_carbohydrates_g', label: 'Daily Carbohydrates (g)', format: 'number' }, + { key: 'daily_protein_g', label: 'Daily Protein (g)', format: 'number' }, + { key: 'daily_fat_g', label: 'Daily Fat (g)', format: 'number' }, + ], + }, + geoDistance: { + fields: [ + { key: 'distance_km', label: 'Distance (km)', format: 'number' }, + { key: 'duration_hours', label: 'Duration Hours', format: 'number', description: 'Whole hours of the travel time.' }, + { + key: 'duration_minutes', + label: 'Duration Minutes', + format: 'number', + description: 'Minutes of the travel time on top of the whole hours.', + }, + ], + }, + csvToJson: { + fields: [ + { + key: 'rows', + label: 'Rows', + value: '', + description: + 'One object per CSV row, keyed by the header row, or field1, field2 and so on when the first row is data.', + }, + ], + }, + currency: { + fields: [ + { key: 'converted_amount', label: 'Converted Amount', format: 'number' }, + { key: 'converted_currency', label: 'Converted Currency' }, + { key: 'original_amount', label: 'Original Amount', format: 'number' }, + { key: 'original_currency', label: 'Original Currency' }, + { + key: 'rate_date', + label: 'Rate Date', + description: 'The date of the exchange rate used, written in the chosen Rate Date Format.', + }, + ], + }, + ipToGeo: { + fields: [ + { key: 'ip', label: 'IP Address' }, + { key: 'country', label: 'Country' }, + { key: 'country_code', label: 'Country Code' }, + { key: 'region_code', label: 'Region Code' }, + { key: 'region_name', label: 'Region Name' }, + { key: 'city', label: 'City' }, + { key: 'zip', label: 'Postal Code' }, + { key: 'latitude', label: 'Latitude', format: 'number' }, + { key: 'longitude', label: 'Longitude', format: 'number' }, + { key: 'time_zone', label: 'Time Zone' }, + { key: 'isp', label: 'Internet Provider' }, + { key: 'organization', label: 'Organization' }, + { key: 'autonomous_system', label: 'Autonomous System' }, + ], + }, + nationIso: { fields: nationIsoFields() }, + randomNumber: { + fields: [{ key: 'number', label: 'Number', format: 'number' }], + }, + randomCity: { + fields: [ + { key: 'city', label: 'City' }, + { key: 'country', label: 'Country' }, + ], + }, + randomName: { fields: randomNameFields() }, +} satisfies Record; + +function calendarWeekFields(): OutputSchema['fields'] { + return [ + { key: 'week_number', label: 'Week Number', format: 'number', description: 'The ISO calendar week number, from 1 to 53.' }, + { + key: 'working_date', + label: 'Working Date', + description: 'The date the week was calculated from, written in the chosen Output Format.', + }, + { + key: 'first_day_of_week', + label: 'First Day of Week', + description: 'The Monday of the week, written in the chosen Output Format.', + }, + { + key: 'last_day_of_week', + label: 'Last Day of Week', + description: 'The Sunday of the week, written in the chosen Output Format.', + }, + ]; +} + +function monthFields(): OutputSchema['fields'] { + return [ + { key: 'days_in_month', label: 'Days in Month', format: 'number' }, + { + key: 'first_day_of_month', + label: 'First Day of Month', + description: 'Written in the chosen Output Format, or a Unix timestamp when Return Timestamps is on.', + }, + { + key: 'last_day_of_month', + label: 'Last Day of Month', + description: 'Written in the chosen Output Format, or a Unix timestamp when Return Timestamps is on.', + }, + { + key: 'last_workday_of_month', + label: 'Last Workday of Month', + description: 'The last Monday to Friday of the month.', + }, + { key: 'workday_count', label: 'Workday Count', format: 'number', description: 'How many Monday to Friday days the month has.' }, + { key: 'workdays', label: 'Workdays', description: 'Every Monday to Friday of the month, earliest first.' }, + { key: 'workdays_reversed', label: 'Workdays (Latest First)', description: 'The same workdays, latest first.' }, + { key: 'saturdays', label: 'Saturdays' }, + { key: 'sundays', label: 'Sundays' }, + ]; +} + +function isWeekendFields(): OutputSchema['fields'] { + return [ + { key: 'is_weekend', label: 'Is Weekend', format: 'boolean' }, + { key: 'weekday', label: 'Weekday', description: 'The English weekday name, for example Monday.' }, + { key: 'day_number', label: 'Day Number', format: 'number', description: 'The weekday as a number.' }, + ]; +} + +function switchTimeZoneFields(): OutputSchema['fields'] { + return [ + { + key: 'converted_time', + label: 'Converted Time', + description: 'The date and time in the destination time zone, written in the chosen Output Format.', + }, + { key: 'time_zone', label: 'Time Zone', description: 'The destination time zone.' }, + ]; +} + +function nationIsoFields(): OutputSchema['fields'] { + return [ + { key: 'iso_code', label: 'ISO Country Code' }, + { key: 'nation', label: 'Country Name' }, + ]; +} + +function randomNameFields(): OutputSchema['fields'] { + return [ + { key: 'first_name', label: 'First Name' }, + { key: 'middle_name', label: 'Middle Name' }, + { key: 'last_name', label: 'Last Name' }, + { key: 'full_name', label: 'Full Name', description: 'First and last name.' }, + { key: 'full_name_with_middle_name', label: 'Full Name with Middle Name' }, + ]; +} diff --git a/packages/pieces/community/0codekit/src/lib/common/props.ts b/packages/pieces/community/0codekit/src/lib/common/props.ts new file mode 100644 index 000000000000..08ac34a928e4 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/props.ts @@ -0,0 +1,130 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const zeroCodeKitProps = { + timeZone, + dateFormat, + outputFormat, + returnTimestamps, + viesCountry, + euCountry, +}; + +function timeZone({ displayName, description, required }: TimeZonePropParams) { + return Property.StaticDropdown({ + displayName, + description, + required, + options: { + options: listTimeZones().map((zone) => ({ label: zone, value: zone })), + }, + }); +} + +function dateFormat({ displayName, description, required }: FormatPropParams) { + return Property.ShortText({ + displayName, + description, + required, + placeholder: 'DD.MM.YYYY', + }); +} + +function outputFormat() { + return Property.ShortText({ + displayName: 'Output Date Format', + description: `How dates in the result are written, using tokens such as \`DD\` (day), \`MM\` (month), \`YYYY\` (year), \`HH:mm\` (time). ${DEFAULT_FORMAT_HINT}`, + required: false, + placeholder: 'DD.MM.YYYY', + }); +} + +function returnTimestamps() { + return Property.Checkbox({ + displayName: 'Return Unix Timestamps', + description: 'Return every date as a Unix timestamp (seconds) instead of formatted text.', + required: false, + defaultValue: false, + }); +} + +function viesCountry() { + return Property.StaticDropdown({ + displayName: 'Country', + description: 'The EU member state that issued the VAT ID.', + required: true, + options: { + options: VIES_COUNTRIES.map((country) => ({ + label: `${country.name} (${country.code})`, + value: country.code, + })), + }, + }); +} + +function euCountry() { + return Property.StaticDropdown({ + displayName: 'Country', + description: 'The EU member state to look up.', + required: true, + options: { + options: EU_COUNTRIES.map((country) => ({ + label: `${country.name} (${country.code})`, + value: country.code, + })), + }, + }); +} + +function listTimeZones(): string[] { + const zones = Intl.supportedValuesOf('timeZone'); + return zones.includes('UTC') ? zones : ['UTC', ...zones]; +} + +const DEFAULT_FORMAT_HINT = 'Leave empty to use the 0CodeKit default.'; + +const EU_COUNTRIES = [ + { code: 'AT', name: 'Austria' }, + { code: 'BE', name: 'Belgium' }, + { code: 'BG', name: 'Bulgaria' }, + { code: 'HR', name: 'Croatia' }, + { code: 'CY', name: 'Cyprus' }, + { code: 'CZ', name: 'Czechia' }, + { code: 'DK', name: 'Denmark' }, + { code: 'EE', name: 'Estonia' }, + { code: 'FI', name: 'Finland' }, + { code: 'FR', name: 'France' }, + { code: 'DE', name: 'Germany' }, + { code: 'GR', name: 'Greece' }, + { code: 'HU', name: 'Hungary' }, + { code: 'IE', name: 'Ireland' }, + { code: 'IT', name: 'Italy' }, + { code: 'LV', name: 'Latvia' }, + { code: 'LT', name: 'Lithuania' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MT', name: 'Malta' }, + { code: 'NL', name: 'Netherlands' }, + { code: 'PL', name: 'Poland' }, + { code: 'PT', name: 'Portugal' }, + { code: 'RO', name: 'Romania' }, + { code: 'SK', name: 'Slovakia' }, + { code: 'SI', name: 'Slovenia' }, + { code: 'ES', name: 'Spain' }, + { code: 'SE', name: 'Sweden' }, +]; + +const VIES_COUNTRIES = [ + ...EU_COUNTRIES.map((country) => (country.code === 'GR' ? { code: 'EL', name: country.name } : country)), + { code: 'XI', name: 'Northern Ireland' }, +]; + +type TimeZonePropParams = { + displayName: string; + description: string; + required: boolean; +}; + +type FormatPropParams = { + displayName: string; + description: string; + required: boolean; +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/random-name.ts b/packages/pieces/community/0codekit/src/lib/common/random-name.ts new file mode 100644 index 000000000000..a349ff54f72e --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/random-name.ts @@ -0,0 +1,41 @@ +import { zeroCodeKitApi } from './client'; + +export const randomNameApi = { + generate, +}; + +async function generate({ apiKey, body }: RandomNameCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/generate/name', + body, + }); + return { + first_name: response.firstName ?? null, + middle_name: response.middleName ?? null, + last_name: response.lastName ?? null, + full_name: response.completeName ?? null, + full_name_with_middle_name: response.completeNameWithMiddleName ?? null, + }; +} + +type RandomNameCall = { + apiKey: string; + body: Record; +}; + +type RandomNameResponse = { + firstName?: string; + middleName?: string; + lastName?: string; + completeName?: string; + completeNameWithMiddleName?: string; +}; + +export type RandomNameOutput = { + first_name: string | null; + middle_name: string | null; + last_name: string | null; + full_name: string | null; + full_name_with_middle_name: string | null; +}; diff --git a/packages/pieces/community/0codekit/src/lib/common/vat.ts b/packages/pieces/community/0codekit/src/lib/common/vat.ts new file mode 100644 index 000000000000..6ab5d7c50573 --- /dev/null +++ b/packages/pieces/community/0codekit/src/lib/common/vat.ts @@ -0,0 +1,44 @@ +import { zeroCodeKitApi } from './client'; + +export const vatApi = { + validate, +}; + +async function validate({ apiKey, body }: VatCall): Promise { + const response = await zeroCodeKitApi.post({ + apiKey, + path: '/business/validate/vat', + body, + }); + return { + valid: response.valid ?? null, + country_code: response.countryCode ?? null, + vat_number: response.vatNumber ?? null, + company_name: response.name ?? null, + company_address: response.address ?? null, + request_date: response.requestDate ?? null, + }; +} + +type VatCall = { + apiKey: string; + body: Record; +}; + +type VatValidationResponse = { + countryCode?: string; + vatNumber?: string; + requestDate?: string; + valid?: boolean; + name?: string; + address?: string; +}; + +export type VatValidationOutput = { + valid: boolean | null; + country_code: string | null; + vat_number: string | null; + company_name: string | null; + company_address: string | null; + request_date: string | null; +}; diff --git a/packages/pieces/community/0codekit/test/actions.test.ts b/packages/pieces/community/0codekit/test/actions.test.ts new file mode 100644 index 000000000000..1be7cff61fd5 --- /dev/null +++ b/packages/pieces/community/0codekit/test/actions.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { zeroCodeKit } from '../src'; +import { lookupVatRatesAction } from '../src/lib/actions/business/lookup-vat-rates'; +import { validateBicAction } from '../src/lib/actions/business/validate-bic'; +import { validateEmailAction } from '../src/lib/actions/business/validate-email'; +import { validateIbanAction } from '../src/lib/actions/business/validate-iban'; +import { validateVatIdAction } from '../src/lib/actions/business/validate-vat-id'; +import { validateVatWithCountryCodeAction } from '../src/lib/actions/business/validate-vat-with-country-code'; +import { verifyDomainAction } from '../src/lib/actions/business/verify-domain'; +import { calculateBmiAction } from '../src/lib/actions/calculate/calculate-bmi'; +import { calculateGeoDistanceAction } from '../src/lib/actions/calculate/calculate-geo-distance'; +import { convertCsvToJsonAction } from '../src/lib/actions/convert/convert-csv-to-json'; +import { convertCurrencyAction } from '../src/lib/actions/convert/convert-currency'; +import { convertIpToGeoAction } from '../src/lib/actions/convert/convert-ip-to-geo'; +import { convertIsoToNationAction } from '../src/lib/actions/convert/convert-iso-to-nation'; +import { convertNationToIsoAction } from '../src/lib/actions/convert/convert-nation-to-iso'; +import { convertTimezoneWithDateAction } from '../src/lib/actions/date-and-time/convert-timezone-with-date'; +import { convertTimezoneWithUnixTimestampAction } from '../src/lib/actions/date-and-time/convert-timezone-with-unix-timestamp'; +import { currentMonthAction } from '../src/lib/actions/date-and-time/current-month'; +import { currentWeekAction } from '../src/lib/actions/date-and-time/current-week'; +import { currentWeekFromDateAction } from '../src/lib/actions/date-and-time/current-week-from-date'; +import { currentWeekFromUnixTimestampAction } from '../src/lib/actions/date-and-time/current-week-from-unix-timestamp'; +import { currentWeekWithWeekNumberAction } from '../src/lib/actions/date-and-time/current-week-with-week-number'; +import { currentWeekWithWeekNumberAndYearAction } from '../src/lib/actions/date-and-time/current-week-with-week-number-and-year'; +import { getHolidaysAction } from '../src/lib/actions/date-and-time/get-holidays'; +import { isWeekendAction } from '../src/lib/actions/date-and-time/is-weekend'; +import { isWeekendWithFormatAction } from '../src/lib/actions/date-and-time/is-weekend-with-format'; +import { dateInput } from '../src/lib/common/date-input'; +import { specificMonthAndYearAction } from '../src/lib/actions/date-and-time/specific-month-and-year'; +import { getANumberAction } from '../src/lib/actions/generate/get-a-number'; +import { getRandomCityAction } from '../src/lib/actions/generate/get-random-city'; +import { getRandomNameAction } from '../src/lib/actions/generate/get-random-name'; +import { getRandomNameWithGenderAction } from '../src/lib/actions/generate/get-random-name-with-gender'; +import { detectGenderAction } from '../src/lib/actions/text/detect-gender'; +import { splitNameAction } from '../src/lib/actions/text/split-name'; +import { textContainsAction } from '../src/lib/actions/text/text-contains'; +import { runAction, TestAction } 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 respond(body: unknown) { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body }); +} + +function sent() { + return sendRequest.mock.calls[0][0]; +} + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('piece metadata', () => { + const actions = Object.values(zeroCodeKit.actions()).filter((action) => action.name !== 'custom_api_call'); + + it('registers all 33 actions plus Custom API Call', () => { + expect(Object.keys(zeroCodeKit.actions())).toHaveLength(34); + }); + + it.each(actions.map((action) => [action.name, action]))('%s carries audience, aiMetadata and classification', (_, action) => { + expect(action.audience).toBe('both'); + expect(action.classification).toBeDefined(); + expect(action.aiMetadata?.description?.length).toBeGreaterThan(20); + expect(typeof action.aiMetadata?.idempotent).toBe('boolean'); + }); + + it('registers Custom API Call', async () => { + const custom = zeroCodeKit.actions()['custom_api_call']; + expect(custom).toBeDefined(); + }); +}); + +describe('Custom API Call', () => { + const custom: TestAction = zeroCodeKit.actions()['custom_api_call']; + + beforeEach(async () => { + const actual = await vi.importActual('@activepieces/pieces-common'); + vi.spyOn(actual.httpClient, 'sendRequest').mockImplementation((...args: unknown[]) => sendRequest(...args)); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function callWith(url: string) { + return runAction({ + action: custom, + propsValue: { url: { url }, method: 'GET', headers: {}, queryParams: {}, failsafe: false }, + }); + } + + it('sends the API key to a path on the 0CodeKit API', async () => { + respond({ ok: true }); + + await callWith('/1saas/auth'); + + expect(sent().url).toBe('https://v2.1saas.co/1saas/auth'); + expect(sent().headers.auth).toBe('zck_test'); + }); + + it('accepts a full URL on the 0CodeKit API host', async () => { + respond({ ok: true }); + + await callWith('https://v2.1saas.co/1saas/auth'); + + expect(sent().headers.auth).toBe('zck_test'); + }); + + it.each(['https://attacker.example/collect', 'https://v2.1saas.co.attacker.example/', 'https://v2.1saas.co@attacker.example/', 'http://v2.1saas.co/1saas/auth'])( + 'refuses to send the API key to %s', + async (url) => { + await expect(callWith(url)).rejects.toThrow(/only sends your 0CodeKit API key/); + expect(sendRequest).not.toHaveBeenCalled(); + }, + ); +}); + +describe('request shape', () => { + const cases: RequestCase[] = [ + { action: currentMonthAction, props: {}, path: '/dateandtime/month', body: {} }, + { + action: currentMonthAction, + props: { outputFormat: 'YYYY-MM-DD', returnTimestamps: true }, + path: '/dateandtime/month', + body: { outputFormat: 'YYYY-MM-DD', options: { timeStamp: true } }, + }, + { + action: specificMonthAndYearAction, + props: { month: 2, year: 2024 }, + path: '/dateandtime/month', + body: { month: 2, year: 2024 }, + }, + { action: currentWeekAction, props: {}, path: '/dateandtime/calendarweek', body: {} }, + { + action: currentWeekFromDateAction, + props: { date: '20.01.2025', dateFormat: 'DD.MM.YYYY' }, + path: '/dateandtime/calendarweek', + body: { date: '20.01.2025', dateFormat: 'DD.MM.YYYY' }, + }, + { + action: currentWeekFromUnixTimestampAction, + props: { unixTimestamp: 1737331200, outputFormat: 'DD/MM' }, + path: '/dateandtime/calendarweek', + body: { unixTimestamp: 1737331200, outputFormat: 'DD/MM' }, + }, + { + action: currentWeekWithWeekNumberAction, + props: { weekNumber: 5 }, + path: '/dateandtime/calendarweek', + body: { date: dateInput.isoWeekMonday({ weekNumber: 5, year: new Date().getUTCFullYear() }), dateFormat: 'DD.MM.YYYY' }, + }, + { + action: currentWeekWithWeekNumberAndYearAction, + props: { weekNumber: 5, year: 2023 }, + path: '/dateandtime/calendarweek', + body: { date: '30.01.2023', dateFormat: 'DD.MM.YYYY' }, + }, + { + action: isWeekendAction, + props: { date: '2025-01-18' }, + path: '/dateandtime/isweekend', + body: { date: '2025-01-18' }, + }, + { + action: isWeekendWithFormatAction, + props: { date: '18.01.2025', dateFormat: 'DD.MM.YYYY', timeZone: 'Europe/Berlin' }, + path: '/dateandtime/isweekend', + body: { date: '2025-01-18', timeZone: 'Europe/Berlin' }, + }, + { + action: getHolidaysAction, + props: { countryCode: ' at ', year: 2022, state: '' }, + path: '/dateandtime/holidays', + body: { countryCode: 'AT', year: 2022 }, + }, + { + action: convertTimezoneWithDateAction, + props: { inputTime: '08-12-2024 12:00', inputTimeZone: 'Europe/Berlin', destinationTimeZone: 'America/New_York' }, + path: '/dateandtime/switchtimezone', + body: { inputTime: '08-12-2024 12:00', inputTimeZone: 'Europe/Berlin', destinationTimeZone: 'America/New_York' }, + }, + { + action: convertTimezoneWithUnixTimestampAction, + props: { inputTime: 1737331200, destinationTimeZone: 'Asia/Tokyo', formatPattern: 'HH:mm' }, + path: '/dateandtime/switchtimezone', + body: { inputTime: 1737331200, inputTimeZone: 'UTC', destinationTimeZone: 'Asia/Tokyo', formatPattern: 'HH:mm' }, + }, + { + action: convertCsvToJsonAction, + props: { csv: 'a,b\n1,2', delimiter: 'auto' }, + path: '/convert/csv/json', + body: { csv: 'a,b\n1,2', delimiter: 'auto', noheader: false, trim: true, ignoreEmpty: false }, + }, + { + action: convertCsvToJsonAction, + props: { csv: 'a;b', delimiter: ';', noHeader: true, trim: false, ignoreEmpty: true }, + path: '/convert/csv/json', + body: { csv: 'a;b', delimiter: [';'], noheader: true, trim: false, ignoreEmpty: true }, + }, + { + action: convertCurrencyAction, + props: { amount: 10, sourceCurrency: 'usd', targetCurrency: ' eur' }, + path: '/convert/currency', + body: { amount: 10, sourceCurrency: 'USD', targetCurrency: 'EUR' }, + }, + { action: convertIpToGeoAction, props: { ip: ' 1.1.1.1 ' }, path: '/convert/iptogeo', body: { ip: '1.1.1.1' } }, + { action: convertIsoToNationAction, props: { iso: 'DE' }, path: '/convert/nationiso', body: { iso: 'DE' } }, + { action: convertNationToIsoAction, props: { nation: 'Germany' }, path: '/convert/nationiso', body: { nation: 'Germany' } }, + { action: calculateBmiAction, props: { weight: 80, height: 180 }, path: '/calculate/bmi', body: { weight: 80, height: 180 } }, + { + action: calculateGeoDistanceAction, + props: { startPoint: 'Flensburg', endPoint: 'Istanbul', mode: 'walking' }, + path: '/calculate/geodistance-v2', + body: { startPoint: 'Flensburg', endPoint: 'Istanbul', mode: 'walking' }, + }, + { action: validateBicAction, props: { bic: 'DEUTDEFF ' }, path: '/business/validate/bic', body: { bic: 'DEUTDEFF' } }, + { action: validateIbanAction, props: { iban: 'DE02120300000000202051' }, path: '/business/validate/iban', body: { iban: 'DE02120300000000202051' } }, + { action: validateEmailAction, props: { email: 'a@gmial.com' }, path: '/business/validate/email', body: { email: 'a@gmial.com' } }, + { + action: validateVatWithCountryCodeAction, + props: { countryCode: 'IT', id: '076 435 20567' }, + path: '/business/validate/vat', + body: { countryCode: 'IT', id: '07643520567' }, + }, + { action: validateVatIdAction, props: { vatId: 'de 123456789' }, path: '/business/validate/vat', body: { vatId: 'DE123456789' } }, + { action: lookupVatRatesAction, props: { countryCode: 'DE' }, path: '/business/lookupvatrates', body: { countryCode: 'DE' } }, + { action: verifyDomainAction, props: { domain: 'https://example.com' }, path: '/business/verify/domain', body: { domain: 'https://example.com' } }, + { action: detectGenderAction, props: { fullName: 'Jane Doe' }, path: '/operator/gender', body: { fullname: 'Jane Doe' } }, + { action: splitNameAction, props: { name: 'Jane Doe' }, path: '/operator/splitname', body: { name: 'Jane Doe', reversed: false } }, + { + action: textContainsAction, + props: { text: 'hello world', keywords: ['world', ' ', 'moon'], caseSensitive: true }, + path: '/text/contains', + body: { + text: 'hello world', + keywordList: ['world', 'moon'], + options: { caseSensitive: true, onlyCompleteWords: false }, + }, + }, + { action: getRandomCityAction, props: {}, path: '/generate/city', body: {} }, + { action: getRandomNameAction, props: {}, path: '/generate/name', body: {} }, + { action: getRandomNameWithGenderAction, props: { gender: 'male' }, path: '/generate/name', body: { gender: 'male' } }, + { + action: getANumberAction, + props: { min: 1, max: 10, type: 'integer', round: 2 }, + path: '/generate/number', + body: { range: [1, 10], type: 'integer' }, + }, + { + action: getANumberAction, + props: { min: 0, max: 1, type: 'decimal', round: 3 }, + path: '/generate/number', + body: { range: [0, 1], type: 'decimal', round: 3 }, + }, + ]; + + it.each(cases.map((testCase) => [testCase.action.name, testCase.path, testCase]))( + '%s posts to %s', + async (_, __, testCase) => { + respond({}); + + await runAction({ action: testCase.action, propsValue: testCase.props }); + + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(sent().method).toBe('POST'); + expect(sent().url).toBe(`https://v2.1saas.co${testCase.path}`); + expect(sent().headers).toEqual({ auth: 'zck_test' }); + expect(sent().body).toEqual(testCase.body); + }, + ); +}); + +describe('outputs', () => { + it('flattens a calendar week', async () => { + respond({ workingDate: '20.01.2025', weekNumber: 4, firstDayOfWeek: '20.01.2025', lastDayOfWeek: '26.01.2025' }); + + await expect(runAction({ action: currentWeekAction, propsValue: {} })).resolves.toEqual({ + week_number: 4, + working_date: '20.01.2025', + first_day_of_week: '20.01.2025', + last_day_of_week: '26.01.2025', + }); + }); + + it('returns month day lists with a workday count', async () => { + respond({ + daysInMonth: 28, + firstDayOfMonth: '01.02.2025', + lastDayOfMonth: '28.02.2025', + lastWorkdayOfMonth: '28.02.2025', + workdays: ['03.02.2025', '04.02.2025'], + workdaysReversed: ['04.02.2025', '03.02.2025'], + saturdays: ['01.02.2025'], + sundays: ['02.02.2025'], + }); + + const result = await runAction({ action: specificMonthAndYearAction, propsValue: { month: 2, year: 2025 } }); + + expect(result).toMatchObject({ days_in_month: 28, workday_count: 2, saturdays: ['01.02.2025'] }); + }); + + it('returns an empty holiday list instead of failing when none are returned', async () => { + respond({}); + + await expect(runAction({ action: getHolidaysAction, propsValue: { countryCode: 'DE', year: 2025 } })).resolves.toEqual([]); + }); + + it('returns one flat row per holiday', async () => { + respond({ + holidays: [ + { date: '2025-01-01 00:00:00', start: 's', end: 'e', name: "New Year's Day", type: 'public', rule: '01-01' }, + ], + }); + + await expect(runAction({ action: getHolidaysAction, propsValue: { countryCode: 'DE', year: 2025 } })).resolves.toEqual([ + { name: "New Year's Day", date: '2025-01-01 00:00:00', type: 'public', start: 's', end: 'e', rule: '01-01' }, + ]); + }); + + it('flattens BMI nutrients', async () => { + respond({ bmi: 24.69, bmiClassification: 'normal', dbw: 72, kcal: 2160, nutrients: { carbohydrates: 324, protein: 81, fat: 60 } }); + + await expect(runAction({ action: calculateBmiAction, propsValue: { weight: 80, height: 180 } })).resolves.toEqual({ + bmi: 24.69, + classification: 'normal', + desirable_body_weight_kg: 72, + daily_calories_kcal: 2160, + daily_carbohydrates_g: 324, + daily_protein_g: 81, + daily_fat_g: 60, + }); + }); + + it('flattens geo distance duration', async () => { + respond({ distanceInKM: 584.2, duration: { hours: 5, minutes: 41 } }); + + await expect( + runAction({ action: calculateGeoDistanceAction, propsValue: { startPoint: 'a', endPoint: 'b', mode: 'driving' } }), + ).resolves.toEqual({ distance_km: 584.2, duration_hours: 5, duration_minutes: 41 }); + }); + + it('turns "false" VAT rates into null so the column stays numeric', async () => { + respond({ + country: 'Denmark', + vat_name: 'Merværdiafgift', + vat_abbr: 'moms', + standard_rate: 25, + reduced_rate: false, + reduced_rate_alt: false, + super_reduced_rate: false, + parking_rate: false, + }); + + const result = await runAction({ action: lookupVatRatesAction, propsValue: { countryCode: 'DK' } }); + + expect(result).toMatchObject({ standard_rate: 25, reduced_rate: null, parking_rate: null, vat_abbreviation: 'moms' }); + }); + + it('returns the registered company for a valid VAT ID', async () => { + respond({ countryCode: 'DE', vatNumber: '123456789', requestDate: '2025-01-20', valid: true, name: 'ACME', address: 'Street 1' }); + + await expect(runAction({ action: validateVatIdAction, propsValue: { vatId: 'DE123456789' } })).resolves.toEqual({ + valid: true, + country_code: 'DE', + vat_number: '123456789', + company_name: 'ACME', + company_address: 'Street 1', + request_date: '2025-01-20', + }); + }); + + it('returns the parsed CSV rows as a list', async () => { + respond({ json: [{ a: '1', b: '2' }] }); + + await expect(runAction({ action: convertCsvToJsonAction, propsValue: { csv: 'a,b\n1,2', delimiter: 'auto' } })).resolves.toEqual([{ a: '1', b: '2' }]); + }); + + it('summarises keyword findings with flat rows', async () => { + respond({ + findings: [ + { keyword: 'world', contains: true, foundPositions: [6, 20] }, + { keyword: 'moon', contains: false, foundPositions: [] }, + ], + }); + + await expect(runAction({ action: textContainsAction, propsValue: { text: 't', keywords: ['world', 'moon'] } })).resolves.toEqual({ + contains_any: true, + contains_all: false, + findings: [ + { keyword: 'world', contains: true, match_count: 2, found_positions: '6, 20' }, + { keyword: 'moon', contains: false, match_count: 0, found_positions: '' }, + ], + }); + }); + + it('maps random name fields to readable keys', async () => { + respond({ firstName: 'Jane', middleName: 'M', lastName: 'Doe', completeName: 'Jane Doe', completeNameWithMiddleName: 'Jane M Doe' }); + + await expect(runAction({ action: getRandomNameAction, propsValue: {} })).resolves.toEqual({ + first_name: 'Jane', + middle_name: 'M', + last_name: 'Doe', + full_name: 'Jane Doe', + full_name_with_middle_name: 'Jane M Doe', + }); + }); + + it('fills missing fields with null instead of dropping them', async () => { + respond({ country: 'Germany' }); + + const result = await runAction({ action: convertIpToGeoAction, propsValue: { ip: '1.1.1.1' } }); + + expect(result).toMatchObject({ country: 'Germany', city: null, latitude: null }); + }); +}); + +describe('input guards', () => { + it('Detect Gender needs a first or full name', async () => { + await expect(runAction({ action: detectGenderAction, propsValue: { firstName: ' ', fullName: '' } })).rejects.toThrow(/First Name or Full Name/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('Text Contains needs at least one keyword', async () => { + await expect(runAction({ action: textContainsAction, propsValue: { text: 't', keywords: [' '] } })).rejects.toThrow(/at least one keyword/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('Get a Number rejects a minimum above the maximum', async () => { + await expect(runAction({ action: getANumberAction, propsValue: { min: 10, max: 1, type: 'integer' } })).rejects.toThrow(/Minimum must be/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +type RequestCase = { + action: TestAction & { name: string }; + props: Record; + path: string; + body: Record; +}; diff --git a/packages/pieces/community/0codekit/test/client.test.ts b/packages/pieces/community/0codekit/test/client.test.ts new file mode 100644 index 000000000000..7522eba5d845 --- /dev/null +++ b/packages/pieces/community/0codekit/test/client.test.ts @@ -0,0 +1,102 @@ +import { HttpError } from '@activepieces/pieces-common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { zeroCodeKitAuth } from '../src/lib/auth'; +import { zeroCodeKitApi } from '../src/lib/common/client'; +import { authValidationServerContext } 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 httpError({ status, body }: { status: number; body: unknown }) { + return new HttpError({}, { status, responseBody: body }); +} + +function validate(auth: string) { + return zeroCodeKitAuth.validate?.({ auth, server: authValidationServerContext() }); +} + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('client', () => { + it('POSTs JSON to v2.1saas.co with the key in the auth header', async () => { + sendRequest.mockResolvedValueOnce({ body: { ok: true } }); + + await zeroCodeKitApi.post({ apiKey: 'k1', path: '/generate/city', body: { a: 1 } }); + + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe('POST'); + expect(request.url).toBe('https://v2.1saas.co/generate/city'); + expect(request.headers).toEqual({ auth: 'k1' }); + expect(request.body).toEqual({ a: 1 }); + }); + + it('drops undefined, null and empty-string fields so API defaults apply', async () => { + sendRequest.mockResolvedValueOnce({ body: {} }); + + await zeroCodeKitApi.post({ + apiKey: 'k1', + path: '/x', + body: { keep: 'v', zero: 0, off: false, gone: undefined, nothing: null, empty: '' }, + }); + + expect(sendRequest.mock.calls[0][0].body).toEqual({ keep: 'v', zero: 0, off: false }); + }); + + it('sends an empty object when no body is given', async () => { + sendRequest.mockResolvedValueOnce({ body: {} }); + + await zeroCodeKitApi.post({ apiKey: 'k1', path: '/generate/city' }); + + expect(sendRequest.mock.calls[0][0].body).toEqual({}); + }); + + it('rewrites 0CodeKit error envelopes into a readable message', async () => { + sendRequest.mockRejectedValueOnce( + httpError({ status: 400, body: { status: 400, errorMessage: 'Error at `body.iban`: Required.', code: 'bad_request' } }), + ); + + await expect(zeroCodeKitApi.post({ apiKey: 'k1', path: '/business/validate/iban' })).rejects.toThrow( + '0CodeKit returned 400: Error at `body.iban`: Required. (bad_request)', + ); + }); +}); + +describe('auth', () => { + it('accepts a key that /1saas/auth authorizes', async () => { + sendRequest.mockResolvedValueOnce({ body: { message: 'You are authorized.' } }); + + await expect(validate('good')).resolves.toEqual({ valid: true }); + expect(sendRequest.mock.calls[0][0].url).toBe('https://v2.1saas.co/1saas/auth'); + expect(sendRequest.mock.calls[0][0].headers).toEqual({ auth: 'good' }); + }); + + it('rejects a key 0CodeKit answers with 401', async () => { + sendRequest.mockRejectedValueOnce( + httpError({ status: 401, body: { status: 401, errorMessage: 'Invalid API key.', code: 'unauthorized' } }), + ); + + const result = await validate('bad'); + + expect(result).toMatchObject({ valid: false }); + expect(result?.valid === false && result.error).toMatch(/rejected this API key/); + }); + + it('reports other failures as a reachability problem, not a bad key', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 503, body: { errorMessage: 'Down for maintenance' } })); + + const result = await validate('any'); + + expect(result?.valid === false && result.error).toMatch(/Could not reach 0CodeKit.*503.*Down for maintenance/); + }); +}); diff --git a/packages/pieces/community/0codekit/test/date-input.test.ts b/packages/pieces/community/0codekit/test/date-input.test.ts new file mode 100644 index 000000000000..cab6031f06cf --- /dev/null +++ b/packages/pieces/community/0codekit/test/date-input.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { dateInput } from '../src/lib/common/date-input'; + +describe('dateInput.isoWeekMonday', () => { + it('returns the Monday of an ISO week', () => { + expect(dateInput.isoWeekMonday({ weekNumber: 10, year: 2024 })).toBe('04.03.2024'); + expect(dateInput.isoWeekMonday({ weekNumber: 1, year: 2024 })).toBe('01.01.2024'); + expect(dateInput.isoWeekMonday({ weekNumber: 1, year: 2021 })).toBe('04.01.2021'); + }); + + it('handles week 53 only in years that have it', () => { + expect(dateInput.isoWeekMonday({ weekNumber: 53, year: 2020 })).toBe('28.12.2020'); + expect(() => dateInput.isoWeekMonday({ weekNumber: 53, year: 2021 })).toThrow(/1 to 52/); + }); + + it('rejects non-whole or out-of-range week numbers', () => { + expect(() => dateInput.isoWeekMonday({ weekNumber: 0, year: 2024 })).toThrow(/does not exist/); + expect(() => dateInput.isoWeekMonday({ weekNumber: 2.5, year: 2024 })).toThrow(/does not exist/); + }); +}); + +describe('dateInput.toIsoDate', () => { + it('parses common formats and separators', () => { + expect(dateInput.toIsoDate({ date: '13.01.2024', format: 'DD.MM.YYYY' })).toBe('2024-01-13'); + expect(dateInput.toIsoDate({ date: '01/13/2024', format: 'MM/DD/YYYY' })).toBe('2024-01-13'); + expect(dateInput.toIsoDate({ date: '2024-1-3', format: 'YYYY-M-D' })).toBe('2024-01-03'); + expect(dateInput.toIsoDate({ date: '13.01.24', format: 'DD.MM.YY' })).toBe('2024-01-13'); + }); + + it('rejects a date that does not match the format', () => { + expect(() => dateInput.toIsoDate({ date: '2024-01-13', format: 'DD.MM.YYYY' })).toThrow(/does not match/); + }); + + it('rejects impossible dates and incomplete formats', () => { + expect(() => dateInput.toIsoDate({ date: '31.02.2024', format: 'DD.MM.YYYY' })).toThrow(/not a real calendar date/); + expect(() => dateInput.toIsoDate({ date: '01.2024', format: 'MM.YYYY' })).toThrow(/must include/); + }); + + it('rejects a format that repeats the year, month or day before matching', () => { + expect(() => dateInput.toIsoDate({ date: '01.01.2024', format: 'DD.D.YYYY' })).toThrow(/more than once/); + expect(() => dateInput.toIsoDate({ date: '2024.1.24', format: 'YYYY.M.YY' })).toThrow(/more than once/); + }); + + it('answers quickly for an adversarial format and date', () => { + const format = `${'MD'.repeat(20)}YYYY`; + const date = `${'1'.repeat(80)}x`; + const startedAt = Date.now(); + expect(() => dateInput.toIsoDate({ date, format })).toThrow(/more than once/); + expect(Date.now() - startedAt).toBeLessThan(100); + }); +}); diff --git a/packages/pieces/community/0codekit/test/hardening.test.ts b/packages/pieces/community/0codekit/test/hardening.test.ts new file mode 100644 index 000000000000..caf6068c5625 --- /dev/null +++ b/packages/pieces/community/0codekit/test/hardening.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { validateIbanAction } from '../src/lib/actions/business/validate-iban'; +import { zeroCodeKitApi, ZEROCODEKIT_TIMEOUT_MS } from '../src/lib/common/client'; +import { runAction } 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), + }, + }; +}); + +const STEP_LIMIT_MS = 600_000; +const write = vi.fn(); + +function respond(body: unknown) { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body }); +} + +function call(index: number) { + return sendRequest.mock.calls[index][0]; +} + +beforeEach(() => { + sendRequest.mockReset(); + write.mockReset(); + write.mockImplementation(async ({ fileName }: { fileName: string }) => `file://${fileName}`); +}); + +describe('request timeouts', () => { + it('gives simple lookups a 30 second timeout', async () => { + respond({ valid: true }); + await runAction({ action: validateIbanAction, propsValue: { iban: 'DE89370400440532013000' }, write }); + expect(call(0).timeout).toBe(30_000); + }); + + it('bounds every timeout class below the step limit', () => { + for (const value of Object.values(ZEROCODEKIT_TIMEOUT_MS)) { + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(STEP_LIMIT_MS); + } + expect(zeroCodeKitApi.timeoutFor('/storage/perm/add')).toBe(120_000); + expect(zeroCodeKitApi.timeoutFor('/generate/qrcode/decode')).toBe(120_000); + expect(zeroCodeKitApi.timeoutFor('/storage/perm/list')).toBe(30_000); + }); + + it('reports a timeout in plain words', async () => { + const abort = new Error('This operation was aborted'); + abort.name = 'AbortError'; + sendRequest.mockRejectedValueOnce(abort); + await expect(runAction({ action: validateIbanAction, propsValue: { iban: 'DE89' }, write })).rejects.toThrow( + /did not answer in time/, + ); + }); +}); diff --git a/packages/pieces/community/0codekit/test/helpers.ts b/packages/pieces/community/0codekit/test/helpers.ts new file mode 100644 index 000000000000..a109f0283dd1 --- /dev/null +++ b/packages/pieces/community/0codekit/test/helpers.ts @@ -0,0 +1,59 @@ +import { + ActionContext, + AppConnectionType, + AppConnectionValueForAuthProperty, + AuthValidationServerContext, + createMockActionContext, + FilesService, + InputPropertyMap, + PropertyContext, + SecretTextProperty, + StaticPropsValue, +} from '@activepieces/pieces-framework'; + +export const TEST_AUTH: SecretTextConnection = { type: AppConnectionType.SECRET_TEXT, secret_text: 'zck_test' }; + +export function runAction({ action, propsValue, write }: RunActionParams): Promise { + const base = createMockActionContext({ propsValue }); + const context: ZeroCodeKitActionContext = { + ...base, + auth: TEST_AUTH, + files: write ? { ...base.files, write } : base.files, + }; + return action.run(context); +} + +export function loadDropdownOptions({ dropdown, auth }: LoadDropdownOptionsParams): Promise { + return dropdown.options({ auth }, propertyContext()); +} + +export function authValidationServerContext(): AuthValidationServerContext { + const { apiUrl, publicUrl } = createMockActionContext({ propsValue: {} }).server; + return { apiUrl, publicUrl, mintOidcToken: async () => 'test-oidc-token' }; +} + +function propertyContext(): PropertyContext { + const { server, project, flows, connections } = createMockActionContext({ propsValue: {} }); + return { server, project, flows, connections }; +} + +type SecretTextConnection = AppConnectionValueForAuthProperty>; + +type ZeroCodeKitActionContext = ActionContext, InputPropertyMap>; + +export type TestAction = { + run(context: ZeroCodeKitActionContext): Promise; +}; + +type RunActionParams = { + action: TestAction; + propsValue: StaticPropsValue; + write?: FilesService['write']; +}; + +type LoadDropdownOptionsParams = { + dropdown: { + options(propsValue: { auth?: SecretTextConnection }, ctx: PropertyContext): Promise; + }; + auth: SecretTextConnection | undefined; +}; diff --git a/packages/pieces/community/0codekit/test/output-schemas-business-ai.test.ts b/packages/pieces/community/0codekit/test/output-schemas-business-ai.test.ts new file mode 100644 index 000000000000..803a52116e2c --- /dev/null +++ b/packages/pieces/community/0codekit/test/output-schemas-business-ai.test.ts @@ -0,0 +1,112 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { lookupVatRatesAction } from '../src/lib/actions/business/lookup-vat-rates'; +import { validateBicAction } from '../src/lib/actions/business/validate-bic'; +import { validateEmailAction } from '../src/lib/actions/business/validate-email'; +import { validateIbanAction } from '../src/lib/actions/business/validate-iban'; +import { validateVatIdAction } from '../src/lib/actions/business/validate-vat-id'; +import { validateVatWithCountryCodeAction } from '../src/lib/actions/business/validate-vat-with-country-code'; +import { verifyDomainAction } from '../src/lib/actions/business/verify-domain'; +import { detectGenderAction } from '../src/lib/actions/text/detect-gender'; +import { splitNameAction } from '../src/lib/actions/text/split-name'; +import { textContainsAction } from '../src/lib/actions/text/text-contains'; +import { runAction, TestAction } 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), + }, + }; +}); + +const VAT_RESPONSE = { + countryCode: 'DE', + vatNumber: '123456789', + requestDate: '2025-01-20', + valid: true, + name: 'ACME', + address: 'Street 1', +}; + +const CASES: SchemaCase[] = [ + { action: validateBicAction, props: { bic: 'DEUTDEFF' }, response: { valid: true } }, + { action: validateIbanAction, props: { iban: 'DE02120300000000202051' }, response: { valid: true } }, + { action: validateEmailAction, props: { email: 'jane@gmial.com' }, response: { valid: true, emailCorrected: 'jane@gmail.com' } }, + { action: validateVatIdAction, props: { vatId: 'DE123456789' }, response: VAT_RESPONSE }, + { action: validateVatWithCountryCodeAction, props: { countryCode: 'DE', id: '123456789' }, response: VAT_RESPONSE }, + { + action: lookupVatRatesAction, + props: { countryCode: 'DE' }, + response: { + country: 'Germany', + vat_name: 'Umsatzsteuer', + vat_abbr: 'USt', + standard_rate: 19, + reduced_rate: 7, + reduced_rate_alt: false, + super_reduced_rate: false, + parking_rate: false, + }, + }, + { action: verifyDomainAction, props: { domain: 'https://www.example.com' }, response: { isValid: true, mainDomain: 'example.com' } }, + { action: detectGenderAction, props: { fullName: 'Jane Doe' }, response: { firstname: 'Jane', lastname: 'Doe', detectedGender: 'female' } }, + { action: splitNameAction, props: { name: 'Jane Doe' }, response: { firstName: 'Jane', lastName: 'Doe' } }, + { + action: textContainsAction, + props: { text: 'the cat sat', keywords: ['cat'] }, + response: { findings: [{ keyword: 'cat', contains: true, foundPositions: [4] }] }, + }, +]; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('business and text output schemas', () => { + it.each(CASES.map((testCase) => [testCase.action.name, testCase]))('%s schema matches its run() output', async (_, testCase) => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: testCase.response }); + + const output = await runAction({ action: testCase.action, propsValue: testCase.props }); + + expect(testCase.action.outputSchema).toBeDefined(); + expectFieldsMatch({ fields: testCase.action.outputSchema?.fields ?? [], value: output }); + }); +}); + +function expectFieldsMatch({ fields, value }: { fields: OutputSchema['fields']; value: unknown }): void { + expect(isRecord(value)).toBe(true); + if (!isRecord(value)) { + return; + } + expect(fields.map((field) => field.value ?? field.key).sort()).toEqual(Object.keys(value).sort()); + for (const field of fields) { + const nested = value[field.value ?? field.key]; + if (field.listItems) { + expect(Array.isArray(nested) && nested.length > 0).toBe(true); + if (Array.isArray(nested)) { + nested.forEach((item) => expectFieldsMatch({ fields: field.listItems ?? [], value: item })); + } + } + if (field.children) { + expectFieldsMatch({ fields: field.children, value: nested }); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type SchemaCase = { + action: TestAction & { + name: string; + outputSchema?: OutputSchema; + }; + props: Record; + response: unknown; +}; diff --git a/packages/pieces/community/0codekit/test/output-schemas-date-convert.test.ts b/packages/pieces/community/0codekit/test/output-schemas-date-convert.test.ts new file mode 100644 index 000000000000..453f890756cd --- /dev/null +++ b/packages/pieces/community/0codekit/test/output-schemas-date-convert.test.ts @@ -0,0 +1,210 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { calculateBmiAction } from '../src/lib/actions/calculate/calculate-bmi'; +import { calculateGeoDistanceAction } from '../src/lib/actions/calculate/calculate-geo-distance'; +import { convertCsvToJsonAction } from '../src/lib/actions/convert/convert-csv-to-json'; +import { convertCurrencyAction } from '../src/lib/actions/convert/convert-currency'; +import { convertIpToGeoAction } from '../src/lib/actions/convert/convert-ip-to-geo'; +import { convertIsoToNationAction } from '../src/lib/actions/convert/convert-iso-to-nation'; +import { convertNationToIsoAction } from '../src/lib/actions/convert/convert-nation-to-iso'; +import { convertTimezoneWithDateAction } from '../src/lib/actions/date-and-time/convert-timezone-with-date'; +import { convertTimezoneWithUnixTimestampAction } from '../src/lib/actions/date-and-time/convert-timezone-with-unix-timestamp'; +import { currentMonthAction } from '../src/lib/actions/date-and-time/current-month'; +import { currentWeekAction } from '../src/lib/actions/date-and-time/current-week'; +import { currentWeekFromDateAction } from '../src/lib/actions/date-and-time/current-week-from-date'; +import { currentWeekFromUnixTimestampAction } from '../src/lib/actions/date-and-time/current-week-from-unix-timestamp'; +import { currentWeekWithWeekNumberAction } from '../src/lib/actions/date-and-time/current-week-with-week-number'; +import { currentWeekWithWeekNumberAndYearAction } from '../src/lib/actions/date-and-time/current-week-with-week-number-and-year'; +import { getHolidaysAction } from '../src/lib/actions/date-and-time/get-holidays'; +import { isWeekendAction } from '../src/lib/actions/date-and-time/is-weekend'; +import { isWeekendWithFormatAction } from '../src/lib/actions/date-and-time/is-weekend-with-format'; +import { specificMonthAndYearAction } from '../src/lib/actions/date-and-time/specific-month-and-year'; +import { getANumberAction } from '../src/lib/actions/generate/get-a-number'; +import { getRandomCityAction } from '../src/lib/actions/generate/get-random-city'; +import { getRandomNameAction } from '../src/lib/actions/generate/get-random-name'; +import { getRandomNameWithGenderAction } from '../src/lib/actions/generate/get-random-name-with-gender'; +import { runAction, TestAction } 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), + }, + }; +}); + +beforeEach(() => { + sendRequest.mockReset(); +}); + +const calendarWeekResponse = { workingDate: '20.01.2025', weekNumber: 4, firstDayOfWeek: '20.01.2025', lastDayOfWeek: '26.01.2025' }; +const monthResponse = { + daysInMonth: 28, + firstDayOfMonth: '01.02.2025', + lastDayOfMonth: '28.02.2025', + lastWorkdayOfMonth: '28.02.2025', + workdays: ['03.02.2025'], + workdaysReversed: ['03.02.2025'], + saturdays: ['01.02.2025'], + sundays: ['02.02.2025'], +}; +const isWeekendResponse = { isWeekend: false, weekDay: 'Monday', dayNumber: 1 }; +const switchTimeZoneResponse = { convertedTime: '20.01.2025 15:30', timeZone: 'Europe/Berlin' }; +const nameResponse = { + firstName: 'Ada', + middleName: 'Byron', + lastName: 'Lovelace', + completeName: 'Ada Lovelace', + completeNameWithMiddleName: 'Ada Byron Lovelace', +}; + +const objectCases: ObjectCase[] = [ + { action: currentWeekAction, props: {}, response: calendarWeekResponse }, + { action: currentWeekFromDateAction, props: { date: '20.01.2025', dateFormat: 'DD.MM.YYYY' }, response: calendarWeekResponse }, + { action: currentWeekFromUnixTimestampAction, props: { unixTimestamp: 1737331200 }, response: calendarWeekResponse }, + { action: currentWeekWithWeekNumberAction, props: { weekNumber: 4 }, response: calendarWeekResponse }, + { action: currentWeekWithWeekNumberAndYearAction, props: { weekNumber: 4, year: 2025 }, response: calendarWeekResponse }, + { action: currentMonthAction, props: {}, response: monthResponse }, + { action: specificMonthAndYearAction, props: { month: 2, year: 2025 }, response: monthResponse }, + { action: isWeekendAction, props: { date: '2025-01-20' }, response: isWeekendResponse }, + { action: isWeekendWithFormatAction, props: { date: '20.01.2025', dateFormat: 'DD.MM.YYYY' }, response: isWeekendResponse }, + { + action: convertTimezoneWithDateAction, + props: { inputTime: '2025-01-20 14:30', inputTimeZone: 'UTC', destinationTimeZone: 'Europe/Berlin' }, + response: switchTimeZoneResponse, + }, + { + action: convertTimezoneWithUnixTimestampAction, + props: { inputTime: 1737331200, destinationTimeZone: 'Europe/Berlin' }, + response: switchTimeZoneResponse, + }, + { + action: calculateBmiAction, + props: { weight: 80, height: 180 }, + response: { bmi: 24.69, bmiClassification: 'normal', dbw: 72, kcal: 2160, nutrients: { carbohydrates: 324, protein: 81, fat: 60 } }, + }, + { + action: calculateGeoDistanceAction, + props: { startPoint: 'Berlin', endPoint: 'Munich', mode: 'driving' }, + response: { distanceInKM: 584, duration: { hours: 5, minutes: 40 } }, + }, + { + action: convertCurrencyAction, + props: { amount: 10, sourceCurrency: 'usd', targetCurrency: 'eur' }, + response: { oldAmount: 10, oldCurrency: 'USD', convertedAmount: 9.2, currency: 'EUR', dataDate: '20.01.2025' }, + }, + { + action: convertIpToGeoAction, + props: { ip: '87.155.190.147' }, + response: { + status: 'success', + country: 'Germany', + countryCode: 'DE', + region: 'BE', + regionName: 'Berlin', + city: 'Berlin', + zip: '10115', + lat: 52.52, + lon: 13.4, + timezone: 'Europe/Berlin', + isp: 'Deutsche Telekom AG', + org: 'Deutsche Telekom AG', + as: 'AS3320 Deutsche Telekom AG', + query: '87.155.190.147', + }, + }, + { action: convertIsoToNationAction, props: { iso: 'DE' }, response: { iso: 'DE', nation: 'Germany' } }, + { action: convertNationToIsoAction, props: { nation: 'Germany' }, response: { iso: 'DE', nation: 'Germany' } }, + { action: getANumberAction, props: { min: 1, max: 10, type: 'integer' }, response: { randomNumber: 7 } }, + { action: getRandomCityAction, props: {}, response: { city: 'Springfield', country: 'Freedonia' } }, + { action: getRandomNameAction, props: {}, response: nameResponse }, + { action: getRandomNameWithGenderAction, props: { gender: 'female' }, response: nameResponse }, +]; + +describe('date, calculate, convert and generate output schemas', () => { + it.each(objectCases.map((testCase) => [testCase.action.name, testCase]))( + '%s schema keys match the run() output keys', + async (_, testCase) => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: testCase.response }); + + const result = await runAction({ action: testCase.action, propsValue: testCase.props }); + + expect(testCase.action.outputSchema).toBeDefined(); + expect(fieldKeys(testCase.action.outputSchema?.fields)).toEqual(Object.keys(objectOf(result)).sort()); + }, + ); + + it('get_holidays wraps the top-level list and its item keys match each holiday row', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: { + holidays: [ + { + date: '2025-01-01 00:00:00', + start: '2024-12-31T23:00:00.000Z', + end: '2025-01-01T23:00:00.000Z', + name: "New Year's Day", + type: 'public', + rule: '01-01', + }, + ], + }, + }); + + const result = await runAction({ action: getHolidaysAction, propsValue: { countryCode: 'DE', year: 2025 } }); + const schema = getHolidaysAction.outputSchema; + const wrapper = schema?.fields[0]; + + expect(schema?.fields).toHaveLength(1); + expect(wrapper?.value).toBe(''); + expect(schema?.itemLabel).toBe('{name}'); + expect(Array.isArray(result)).toBe(true); + expect(fieldKeys(wrapper?.listItems)).toEqual(Object.keys(objectOf(firstOf(result))).sort()); + }); + + it('convert_csv_to_json wraps the top-level list without fixed item fields', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { json: [{ name: 'Ada' }] } }); + + const result = await runAction({ action: convertCsvToJsonAction, propsValue: { csv: 'name\nAda', delimiter: 'auto' } }); + const schema = convertCsvToJsonAction.outputSchema; + + expect(Array.isArray(result)).toBe(true); + expect(schema?.fields).toHaveLength(1); + expect(schema?.fields[0].value).toBe(''); + expect(schema?.fields[0].listItems).toBeUndefined(); + }); +}); + +function fieldKeys(fields: OutputSchema['fields'] | undefined): string[] { + return (fields ?? []).map((field) => field.value ?? field.key).sort(); +} + +function objectOf(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Expected an object output'); + } + return Object.fromEntries(Object.entries(value)); +} + +function firstOf(value: unknown): unknown { + if (!Array.isArray(value)) { + throw new Error('Expected an array output'); + } + return value[0]; +} + +type SchemaAction = TestAction & { + name: string; + outputSchema?: OutputSchema; +}; + +type ObjectCase = { + action: SchemaAction; + props: Record; + response: Record; +}; diff --git a/packages/pieces/community/0codekit/tsconfig.json b/packages/pieces/community/0codekit/tsconfig.json new file mode 100644 index 000000000000..71bc5814f5de --- /dev/null +++ b/packages/pieces/community/0codekit/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/pieces/community/0codekit/tsconfig.lib.json b/packages/pieces/community/0codekit/tsconfig.lib.json new file mode 100644 index 000000000000..a648eb45d7fa --- /dev/null +++ b/packages/pieces/community/0codekit/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/pieces/community/0codekit/vitest.config.ts b/packages/pieces/community/0codekit/vitest.config.ts new file mode 100644 index 000000000000..79c972619047 --- /dev/null +++ b/packages/pieces/community/0codekit/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'), + }, + }, +}) diff --git a/packages/pieces/community/convertapi/.eslintrc.json b/packages/pieces/community/convertapi/.eslintrc.json new file mode 100644 index 000000000000..9ee1a0770e89 --- /dev/null +++ b/packages/pieces/community/convertapi/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "lodash", + "lodash/*", + "@activepieces/core-*", + "@activepieces/server*", + "@activepieces/engine", + "@activepieces/shared" + ] + } + ] + } + }, + { "files": ["*.js", "*.jsx"], "rules": {} } + ] +} diff --git a/packages/pieces/community/convertapi/package.json b/packages/pieces/community/convertapi/package.json new file mode 100644 index 000000000000..9a823efc6e34 --- /dev/null +++ b/packages/pieces/community/convertapi/package.json @@ -0,0 +1,20 @@ +{ + "name": "@activepieces/piece-convertapi", + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "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'", + "test": "vitest run" + }, + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*" + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6" + } +} diff --git a/packages/pieces/community/convertapi/src/i18n/translation.json b/packages/pieces/community/convertapi/src/i18n/translation.json new file mode 100644 index 000000000000..2a2b00cb1c08 --- /dev/null +++ b/packages/pieces/community/convertapi/src/i18n/translation.json @@ -0,0 +1,85 @@ +{ + "Convert, merge and split documents, images and PDFs with 300+ file converters.": "Convert, merge and split documents, images and PDFs with 300+ file converters.", + "To get your API token:\n1. Sign in to [ConvertAPI](https://www.convertapi.com/a).\n2. Open **Authentication** (https://www.convertapi.com/a/authentication).\n3. Copy an existing **API Token**, or create a new one, and paste it here.": "To get your API token:\n1. Sign in to [ConvertAPI](https://www.convertapi.com/a).\n2. Open **Authentication** (https://www.convertapi.com/a/authentication).\n3. Copy an existing **API Token**, or create a new one, and paste it here.", + "Merge PDF Files": "Merge PDF Files", + "Split PDF File": "Split PDF File", + "Convert File": "Convert File", + "Custom API Call": "Custom API Call", + "Combine several PDF files into one PDF, in the order you list them.": "Combine several PDF files into one PDF, in the order you list them.", + "Split a PDF into several PDFs by page count, page ranges, single pages, matching text or bookmarks.": "Split a PDF into several PDFs by page count, page ranges, single pages, matching text or bookmarks.", + "Convert a file to another format with any of ConvertAPI's converters, such as Word to PDF or PDF to JPG. Extra file inputs (like a template or overlay file) are not supported here; use Custom API Call for those.": "Convert a file to another format with any of ConvertAPI's converters, such as Word to PDF or PDF to JPG. Extra file inputs (like a template or overlay file) are not supported here; use Custom API Call for those.", + "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", + "PDF Files": "PDF Files", + "Output File Name": "Output File Name", + "Password": "Password", + "Bookmark Each File": "Bookmark Each File", + "Page Size": "Page Size", + "Page Orientation": "Page Orientation", + "PDF File": "PDF File", + "Split By": "Split By", + "Markdown": "Markdown", + "Split Value": "Split Value", + "Merge Parts Into One PDF": "Merge Parts Into One PDF", + "From Format": "From Format", + "To Format": "To Format", + "File": "File", + "Additional Files": "Additional Files", + "Conversion Options": "Conversion Options", + "Method": "Method", + "Headers": "Headers", + "Query Parameters": "Query Parameters", + "Body Type": "Body Type", + "Body": "Body", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", + "Follow redirects": "Follow redirects", + "Add the PDFs in the order they should appear. At least two are needed.": "Add the PDFs in the order they should appear. At least two are needed.", + "Merged file name, without the extension. Leave empty for a default.": "Merged file name, without the extension. Leave empty for a default.", + "Only needed if the PDFs are password-protected. Used for every file.": "Only needed if the PDFs are password-protected. Used for every file.", + "Add a top-level bookmark for each merged file.": "Add a top-level bookmark for each merged file.", + "Scale every page to this size, or keep the original size.": "Scale every page to this size, or keep the original size.", + "Rotate every page to this orientation, or keep the original.": "Rotate every page to this orientation, or keep the original.", + "The PDF to split.": "The PDF to split.", + "How the PDF is cut into parts.": "How the PDF is cut into parts.", + "**Split Value** by mode:\n- **Page count**: pages per part, e.g. `5`, or a repeating pattern such as `3,2`. Leave empty for one file per page.\n- **Page ranges**: one part per range, e.g. `1-3,5,7-9` gives three files.\n- **Single pages**: each listed page becomes its own file, e.g. `1-2,5` gives pages 1, 2 and 5.\n- **Text match**: a regular expression; a new part starts at every page whose text matches, e.g. `Invoice\\s+#\\d+`.\n- **Bookmarks**: leave empty.": "**Split Value** by mode:\n- **Page count**: pages per part, e.g. `5`, or a repeating pattern such as `3,2`. Leave empty for one file per page.\n- **Page ranges**: one part per range, e.g. `1-3,5,7-9` gives three files.\n- **Single pages**: each listed page becomes its own file, e.g. `1-2,5` gives pages 1, 2 and 5.\n- **Text match**: a regular expression; a new part starts at every page whose text matches, e.g. `Invoice\\s+#\\d+`.\n- **Bookmarks**: leave empty.", + "What to split on. The format depends on the Split By mode.": "What to split on. The format depends on the Split By mode.", + "Join the parts back into one PDF, e.g. to extract selected pages.": "Join the parts back into one PDF, e.g. to extract selected pages.", + "Only needed if the PDF is password-protected.": "Only needed if the PDF is password-protected.", + "Base name for the parts, without extension. Each part gets a number.": "Base name for the parts, without extension. Each part gets a number.", + "The format of the file you are converting.": "The format of the file you are converting.", + "What to convert the file into.": "What to convert the file into.", + "The file to convert.": "The file to convert.", + "Files to add after the main one, for converters that combine files.": "Files to add after the main one, for converters that combine files.", + "Options of the chosen converter. Leave empty for the defaults.": "Options of the chosen converter. Leave empty for the defaults.", + "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.", + "No bookmarks": "No bookmarks", + "Use file names": "Use file names", + "Use PDF titles": "Use PDF titles", + "Original": "Original", + "A2": "A2", + "A3": "A3", + "A4": "A4", + "A5": "A5", + "A6": "A6", + "Letter": "Letter", + "Legal": "Legal", + "Portrait": "Portrait", + "Landscape": "Landscape", + "Page count (every N pages)": "Page count (every N pages)", + "Page ranges": "Page ranges", + "Single pages": "Single pages", + "Text match (regular expression)": "Text match (regular expression)", + "Bookmarks": "Bookmarks", + "GET": "GET", + "POST": "POST", + "PATCH": "PATCH", + "PUT": "PUT", + "DELETE": "DELETE", + "HEAD": "HEAD" +} \ No newline at end of file diff --git a/packages/pieces/community/convertapi/src/index.ts b/packages/pieces/community/convertapi/src/index.ts new file mode 100644 index 000000000000..ef1426fc455c --- /dev/null +++ b/packages/pieces/community/convertapi/src/index.ts @@ -0,0 +1,30 @@ +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { convertFileAction } from './lib/actions/convert-file'; +import { mergePdfAction } from './lib/actions/merge-pdf'; +import { splitPdfAction } from './lib/actions/split-pdf'; +import { convertApiAuth } from './lib/auth'; +import { CONVERTAPI_BASE_URL } from './lib/common/client'; + +export const convertapi = createPiece({ + displayName: 'ConvertAPI', + description: 'Convert, merge and split documents, images and PDFs with 300+ file converters.', + minimumSupportedRelease: '0.82.0', + logoUrl: 'https://cdn.activepieces.com/pieces/convertapi.png', + categories: [PieceCategory.CONTENT_AND_FILES], + auth: convertApiAuth, + authors: ['OdaiAhmed99'], + actions: [ + mergePdfAction, + splitPdfAction, + convertFileAction, + createCustomApiCallAction({ + baseUrl: () => CONVERTAPI_BASE_URL, + auth: convertApiAuth, + authMapping: async (auth) => ({ + Authorization: `Bearer ${auth.secret_text}`, + }), + }), + ], + triggers: [], +}); diff --git a/packages/pieces/community/convertapi/src/lib/actions/convert-file.ts b/packages/pieces/community/convertapi/src/lib/actions/convert-file.ts new file mode 100644 index 000000000000..fe20cb31be0a --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/actions/convert-file.ts @@ -0,0 +1,127 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertApiAuth } from '../auth'; +import { convertApi } from '../common/client'; +import { converterInfo } from '../common/converters'; +import { inputFiles } from '../common/input-files'; +import { convertFileOutputSchema } from '../output-schemas'; + +export const convertFileAction = createAction({ + auth: convertApiAuth, + name: 'convert_file', + classification: 'WRITE', + displayName: 'Convert File', + description: 'Convert a file to another format with any of ConvertAPI\'s converters, such as Word to PDF or PDF to JPG. Extra file inputs (like a template or overlay file) are not supported here; use Custom API Call for those.', + audience: 'both', + aiMetadata: { + description: + 'Converts a file between formats with any ConvertAPI converter (Office, PDF, images, HTML, CAD, email and more) and returns a list with one stored file per output, since some conversions produce several files (for example one image per PDF page). Prefer Merge PDF Files or Split PDF File for those PDF jobs. Secondary file inputs such as TemplateFile or OverlayFile are not exposed; use Custom API Call for converters that need them. Each call uses ConvertAPI conversions and creates new output files.', + idempotent: false, + }, + props: { + from: Property.Dropdown({ + auth: convertApiAuth, + displayName: 'From Format', + description: 'The format of the file you are converting.', + required: true, + refreshers: [], + options: async () => { + const converters = await converterInfo.fetchConverters(); + const options = converterInfo.sourceOptions(converters); + if (options.length === 0) { + return { disabled: true, options: [], placeholder: 'Could not load formats from ConvertAPI' }; + } + return { disabled: false, options }; + }, + }), + to: Property.Dropdown({ + auth: convertApiAuth, + displayName: 'To Format', + description: 'What to convert the file into.', + required: true, + refreshers: ['from'], + options: async ({ from }) => { + if (typeof from !== 'string' || from.length === 0) { + return { disabled: true, options: [], placeholder: 'Select a From Format first' }; + } + const converters = await converterInfo.fetchConverters({ from }); + const options = converterInfo.destinationOptions({ converters, from }); + if (options.length === 0) { + return { disabled: true, options: [], placeholder: 'No conversions available for this format' }; + } + return { disabled: false, options }; + }, + }), + file: Property.File({ + displayName: 'File', + description: 'The file to convert.', + required: true, + }), + additionalFiles: Property.Array({ + displayName: 'Additional Files', + description: 'Files to add after the main one, for converters that combine files.', + required: false, + properties: { + file: Property.File({ + displayName: 'File', + required: true, + }), + }, + }), + options: Property.DynamicProperties({ + auth: convertApiAuth, + displayName: 'Conversion Options', + description: 'Options of the chosen converter. Leave empty for the defaults.', + required: false, + refreshers: ['from', 'to'], + props: async ({ from, to }) => { + if (typeof from !== 'string' || typeof to !== 'string' || from.length === 0 || to.length === 0) { + return {}; + } + const converter = await converterInfo.fetchConverter({ from, to }); + return converterInfo.optionProps(converter); + }, + }), + }, + outputSchema: convertFileOutputSchema, + async run({ auth, propsValue, files }) { + const converter = await converterInfo.fetchConverter({ from: propsValue.from, to: propsValue.to }); + const mainInput = converterInfo.mainInputOf(converter); + if (mainInput === undefined) { + throw new Error(`The ${propsValue.from} to ${propsValue.to} converter does not take a file as input.`); + } + const additional = inputFiles.fromArrayItems({ items: propsValue.additionalFiles, key: 'file' }); + if (!mainInput.multiple && additional.length > 0) { + throw new Error(`The ${propsValue.from} to ${propsValue.to} converter takes a single file. Remove the Additional Files.`); + } + + const result = await convertApi.convertFiles({ + apiKey: auth.secret_text, + from: propsValue.from, + to: propsValue.to, + fileInputs: [ + { name: mainInput.name, files: [propsValue.file, ...additional], multiple: mainInput.multiple }, + ], + parameters: convertApi.toValueParameters( + withDefaultFileName({ options: propsValue.options ?? {}, inputName: propsValue.file.filename }), + ), + files, + }); + + return result.files; + }, +}); + +function withDefaultFileName({ + options, + inputName, +}: { + options: Record; + inputName: string; +}): Record { + const current = options['FileName']; + const hasFileName = typeof current === 'string' && current.trim() !== ''; + if (hasFileName || !inputName.startsWith('unknown.')) { + return options; + } + return { ...options, FileName: 'converted' }; +} diff --git a/packages/pieces/community/convertapi/src/lib/actions/merge-pdf.ts b/packages/pieces/community/convertapi/src/lib/actions/merge-pdf.ts new file mode 100644 index 000000000000..3da8a471399c --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/actions/merge-pdf.ts @@ -0,0 +1,114 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertApiAuth } from '../auth'; +import { convertApi } from '../common/client'; +import { inputFiles } from '../common/input-files'; +import { mergePdfOutputSchema } from '../output-schemas'; + +export const mergePdfAction = createAction({ + auth: convertApiAuth, + name: 'merge_pdf', + classification: 'WRITE', + displayName: 'Merge PDF Files', + description: 'Combine several PDF files into one PDF, in the order you list them.', + audience: 'both', + aiMetadata: { + description: + 'Merges two or more PDF files into a single new PDF with ConvertAPI, keeping the listed order, and returns it as a stored file. Use Split PDF File to break a PDF apart, or Convert File to merge Word or PowerPoint documents. Each call uses ConvertAPI conversions and creates a new output file.', + idempotent: false, + }, + props: { + files: Property.Array({ + displayName: 'PDF Files', + description: 'Add the PDFs in the order they should appear. At least two are needed.', + required: true, + properties: { + file: Property.File({ + displayName: 'PDF File', + required: true, + }), + }, + }), + fileName: Property.ShortText({ + displayName: 'Output File Name', + description: 'Merged file name, without the extension. Leave empty for a default.', + placeholder: 'e.g. combined', + required: false, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Only needed if the PDFs are password-protected. Used for every file.', + required: false, + }), + bookmarksTableOfContents: Property.StaticDropdown({ + displayName: 'Bookmark Each File', + description: 'Add a top-level bookmark for each merged file.', + required: false, + defaultValue: 'disabled', + options: { + options: [ + { label: 'No bookmarks', value: 'disabled' }, + { label: 'Use file names', value: 'filename' }, + { label: 'Use PDF titles', value: 'title' }, + ], + }, + }), + pageSize: Property.StaticDropdown({ + displayName: 'Page Size', + description: 'Scale every page to this size, or keep the original size.', + required: false, + defaultValue: 'default', + options: { + options: [ + { label: 'Original', value: 'default' }, + { label: 'A2', value: 'a2' }, + { label: 'A3', value: 'a3' }, + { label: 'A4', value: 'a4' }, + { label: 'A5', value: 'a5' }, + { label: 'A6', value: 'a6' }, + { label: 'Letter', value: 'letter' }, + { label: 'Legal', value: 'legal' }, + ], + }, + }), + pageOrientation: Property.StaticDropdown({ + displayName: 'Page Orientation', + description: 'Rotate every page to this orientation, or keep the original.', + required: false, + defaultValue: 'default', + options: { + options: [ + { label: 'Original', value: 'default' }, + { label: 'Portrait', value: 'portrait' }, + { label: 'Landscape', value: 'landscape' }, + ], + }, + }), + }, + outputSchema: mergePdfOutputSchema, + async run({ auth, propsValue, files }) { + const pdfFiles = inputFiles.fromArrayItems({ items: propsValue.files, key: 'file' }); + if (pdfFiles.length < 2) { + throw new Error('Add at least two PDF files to merge.'); + } + + const result = await convertApi.convertFiles({ + apiKey: auth.secret_text, + from: 'pdf', + to: 'merge', + fileInputs: [{ name: 'Files', files: pdfFiles, multiple: true }], + parameters: convertApi.toValueParameters({ + FileName: propsValue.fileName, + Password: propsValue.password, + BookmarksTableOfContents: propsValue.bookmarksTableOfContents, + PageSize: propsValue.pageSize, + PageOrientation: propsValue.pageOrientation, + }), + files, + }); + + return { + ...result.files[0], + conversion_cost: result.conversion_cost, + }; + }, +}); diff --git a/packages/pieces/community/convertapi/src/lib/actions/split-pdf.ts b/packages/pieces/community/convertapi/src/lib/actions/split-pdf.ts new file mode 100644 index 000000000000..9fbc45f08514 --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/actions/split-pdf.ts @@ -0,0 +1,99 @@ +import { createAction, MarkdownVariant, Property } from '@activepieces/pieces-framework'; +import { convertApiAuth } from '../auth'; +import { convertApi } from '../common/client'; +import { splitPdfOutputSchema } from '../output-schemas'; + +export const splitPdfAction = createAction({ + auth: convertApiAuth, + name: 'split_pdf', + classification: 'WRITE', + displayName: 'Split PDF File', + description: 'Split a PDF into several PDFs by page count, page ranges, single pages, matching text or bookmarks.', + audience: 'both', + aiMetadata: { + description: + 'Splits one PDF into multiple new PDFs with ConvertAPI and returns a list with one stored file per part. Pick the mode that matches the request: fixed page counts, explicit page ranges, individual pages, a regular expression that starts each new part, or the document bookmarks. Use Merge PDF Files for the opposite. Each call uses ConvertAPI conversions and creates new output files.', + idempotent: false, + }, + props: { + file: Property.File({ + displayName: 'PDF File', + description: 'The PDF to split.', + required: true, + }), + mode: Property.StaticDropdown({ + displayName: 'Split By', + description: 'How the PDF is cut into parts.', + required: true, + defaultValue: 'pagecount', + options: { + options: [ + { label: 'Page count (every N pages)', value: 'pagecount' }, + { label: 'Page ranges', value: 'ranges' }, + { label: 'Single pages', value: 'singlepages' }, + { label: 'Text match (regular expression)', value: 'text' }, + { label: 'Bookmarks', value: 'bookmark' }, + ], + }, + }), + valueHelp: Property.MarkDown({ + variant: MarkdownVariant.INFO, + value: `**Split Value** by mode: +- **Page count**: pages per part, e.g. \`5\`, or a repeating pattern such as \`3,2\`. Leave empty for one file per page. +- **Page ranges**: one part per range, e.g. \`1-3,5,7-9\` gives three files. +- **Single pages**: each listed page becomes its own file, e.g. \`1-2,5\` gives pages 1, 2 and 5. +- **Text match**: a regular expression; a new part starts at every page whose text matches, e.g. \`Invoice\\s+#\\d+\`. +- **Bookmarks**: leave empty.`, + }), + value: Property.ShortText({ + displayName: 'Split Value', + description: 'What to split on. The format depends on the Split By mode.', + placeholder: 'e.g. 1-3,5,7-9', + required: false, + }), + mergeOutput: Property.Checkbox({ + displayName: 'Merge Parts Into One PDF', + description: 'Join the parts back into one PDF, e.g. to extract selected pages.', + required: false, + defaultValue: false, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Only needed if the PDF is password-protected.', + required: false, + }), + fileName: Property.ShortText({ + displayName: 'Output File Name', + description: 'Base name for the parts, without extension. Each part gets a number.', + placeholder: 'e.g. invoice', + required: false, + }), + }, + outputSchema: splitPdfOutputSchema, + async run({ auth, propsValue, files }) { + const mode = propsValue.mode ?? 'pagecount'; + const value = propsValue.value?.trim() ?? ''; + if (MODES_REQUIRING_VALUE.includes(mode) && value.length === 0) { + throw new Error(`Enter a Split Value for the "${mode}" mode.`); + } + + const result = await convertApi.convertFiles({ + apiKey: auth.secret_text, + from: 'pdf', + to: 'split', + fileInputs: [{ name: 'File', files: [propsValue.file], multiple: false }], + parameters: convertApi.toValueParameters({ + Mode: mode, + Value: mode === 'bookmark' ? undefined : value, + MergeOutput: propsValue.mergeOutput === true ? true : undefined, + Password: propsValue.password, + FileName: propsValue.fileName, + }), + files, + }); + + return result.files; + }, +}); + +const MODES_REQUIRING_VALUE = ['ranges', 'singlepages', 'text']; diff --git a/packages/pieces/community/convertapi/src/lib/auth.ts b/packages/pieces/community/convertapi/src/lib/auth.ts new file mode 100644 index 000000000000..179311b874df --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/auth.ts @@ -0,0 +1,12 @@ +import { PieceAuth } from '@activepieces/pieces-framework'; +import { convertApi } from './common/client'; + +export const convertApiAuth = PieceAuth.SecretText({ + displayName: 'API Token', + description: `To get your API token: +1. Sign in to [ConvertAPI](https://www.convertapi.com/a). +2. Open **Authentication** (https://www.convertapi.com/a/authentication). +3. Copy an existing **API Token**, or create a new one, and paste it here.`, + required: true, + validate: async ({ auth }) => convertApi.checkToken({ apiKey: auth }), +}); diff --git a/packages/pieces/community/convertapi/src/lib/common/client.ts b/packages/pieces/community/convertapi/src/lib/common/client.ts new file mode 100644 index 000000000000..1b1ae06c4e34 --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/common/client.ts @@ -0,0 +1,347 @@ +import { httpClient, HttpError, HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { FilesService } from '@activepieces/pieces-framework'; +import { + ConvertApiConversionResponse, + ConvertApiErrorBody, + ConvertApiParameter, + ConvertApiResultFile, + ConvertApiUploadResponse, + InputFile, + StoredFile, +} from './types'; + +export const convertApi = { + upload, + convert, + download, + deleteFiles, + convertFiles, + checkToken, + toValueParameters, + describeError, +}; + +export const CONVERTAPI_BASE_URL = 'https://v2.convertapi.com'; +export const MAX_TIMEOUT_SECONDS = 360; +export const DOWNLOAD_TIMEOUT_MS = 60_000; +export const DELETE_TIMEOUT_MS = 15_000; +export const UPLOAD_TIMEOUT_MS = 120_000; +export const STEP_BUDGET_MS = 540_000; + +async function upload({ apiKey, file, timeoutMs }: UploadParams): Promise { + const response = await sendOrThrow({ + method: HttpMethod.POST, + url: `${CONVERTAPI_BASE_URL}/upload`, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/octet-stream', + }, + queryParams: { filename: file.filename }, + body: file.data, + timeout: timeoutMs, + }); + if (typeof response.FileId !== 'string' || response.FileId.length === 0) { + throw new Error(`ConvertAPI did not return a file ID after uploading "${file.filename}".`); + } + return response.FileId; +} + +async function convert({ apiKey, from, to, parameters, timeoutMs }: ConvertParams): Promise { + return sendOrThrow({ + method: HttpMethod.POST, + url: `${CONVERTAPI_BASE_URL}/convert/${encodeURIComponent(from)}/to/${encodeURIComponent(to)}`, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + body: { Parameters: withExecutionParameters(parameters) }, + timeout: timeoutMs ?? (MAX_TIMEOUT_SECONDS + 30) * 1000, + }); +} + +async function download({ url, timeoutMs }: { url: string; timeoutMs?: number }): Promise { + if (!isConvertApiUrl(url)) { + throw new Error('ConvertAPI returned a download link outside convertapi.com, so it was not followed.'); + } + const response = await sendOrThrow({ + method: HttpMethod.GET, + url, + responseType: 'arraybuffer', + followRedirects: false, + timeout: timeoutMs ?? DOWNLOAD_TIMEOUT_MS, + }); + if (!Buffer.isBuffer(response)) { + throw new Error('ConvertAPI returned an unreadable file.'); + } + return response; +} + +async function deleteFiles({ fileIds }: { fileIds: string[] }): Promise { + const results = await Promise.allSettled( + fileIds.map((fileId) => + httpClient.sendRequest({ + method: HttpMethod.DELETE, + url: `${CONVERTAPI_BASE_URL}/d/${encodeURIComponent(fileId)}`, + timeout: DELETE_TIMEOUT_MS, + }), + ), + ); + return fileIds.filter((_, index) => results[index].status === 'rejected'); +} + +async function convertFiles({ + apiKey, + from, + to, + fileInputs, + parameters, + files, +}: ConvertFilesParams): Promise { + const fileIdsToDelete: string[] = []; + const deadline = Date.now() + STEP_BUDGET_MS; + try { + const fileParameters: ConvertApiParameter[] = []; + for (const input of fileInputs) { + const ids: string[] = []; + for (const file of input.files) { + const id = await upload({ apiKey, file, timeoutMs: remainingMs({ deadline, cap: UPLOAD_TIMEOUT_MS }) }); + fileIdsToDelete.push(id); + ids.push(id); + } + fileParameters.push(toFileParameter({ name: input.name, ids, multiple: input.multiple })); + } + + const response = await convert({ + apiKey, + from, + to, + parameters: [...fileParameters, ...parameters], + timeoutMs: remainingMs({ deadline, cap: (MAX_TIMEOUT_SECONDS + 30) * 1000 }), + }); + const resultFiles = response.Files ?? []; + if (resultFiles.length === 0) { + throw new Error('ConvertAPI finished the conversion but returned no files.'); + } + fileIdsToDelete.push( + ...resultFiles.flatMap((resultFile) => (resultFile.FileId ? [resultFile.FileId] : [])), + ); + + const downloadTimeoutMs = remainingMs({ deadline, cap: DOWNLOAD_TIMEOUT_MS }); + const settled = await Promise.allSettled( + resultFiles.map((resultFile) => storeResultFile({ resultFile, files, timeoutMs: downloadTimeoutMs })), + ); + const failure = settled.find((result) => result.status === 'rejected'); + if (failure !== undefined && failure.status === 'rejected') { + throw failure.reason; + } + const stored = settled.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])); + return { + files: stored, + conversion_cost: typeof response.ConversionCost === 'number' ? response.ConversionCost : null, + }; + } finally { + const notDeleted = await deleteFiles({ fileIds: fileIdsToDelete }); + if (notDeleted.length > 0) { + console.warn( + `[convertapi] Could not delete ${notDeleted.length} file(s) from ConvertAPI after the step: ${notDeleted.join(', ')}. ConvertAPI removes stored files automatically after a few hours.`, + ); + } + } +} + +async function checkToken({ apiKey }: { apiKey: string }): Promise { + try { + await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${CONVERTAPI_BASE_URL}/convert/pdf/to/merge`, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + body: { Parameters: [] }, + }); + return { valid: true }; + } catch (error) { + const status = statusOf(error); + if (status === 401) { + return { + valid: false, + error: 'ConvertAPI rejected this API token. Copy an active token from https://www.convertapi.com/a/authentication and try again.', + }; + } + if (status !== undefined && status >= 400 && status < 500) { + return { valid: true }; + } + return { + valid: false, + error: `Could not reach ConvertAPI to check this token: ${describeError(error)}`, + }; + } +} + +function withExecutionParameters(parameters: ConvertApiParameter[]): ConvertApiParameter[] { + const reserved = new Set(RESERVED_PARAMETER_NAMES.map((name) => name.toLowerCase())); + return [ + ...parameters.filter((parameter) => !reserved.has(parameter.Name.toLowerCase())), + { Name: 'StoreFile', Value: 'true' }, + { Name: 'Timeout', Value: String(MAX_TIMEOUT_SECONDS) }, + ]; +} + +function toFileParameter({ name, ids, multiple }: { name: string; ids: string[]; multiple: boolean }): ConvertApiParameter { + if (multiple) { + return { Name: name, FileValues: ids.map((id) => ({ Id: id })) }; + } + return { Name: name, FileValue: { Id: ids[0] } }; +} + +function toValueParameters(values: Record): ConvertApiParameter[] { + return Object.entries(values).flatMap(([name, value]) => { + if (value === undefined || value === null || value === '') { + return []; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return [{ Name: name, Value: String(value) }]; + } + return []; + }); +} + +async function storeResultFile({ + resultFile, + files, + timeoutMs, +}: { + resultFile: ConvertApiResultFile; + files: FilesService; + timeoutMs: number; +}): Promise { + const url = resultFile.Url ?? resultFile.FileUrl; + if (url === undefined || url.length === 0) { + throw new Error(`ConvertAPI did not return a download link for "${resultFile.FileName}".`); + } + const data = await download({ url, timeoutMs }); + const file = await files.write({ fileName: resultFile.FileName, data }); + return { + file, + file_name: resultFile.FileName, + file_extension: resultFile.FileExt ?? null, + file_size: typeof resultFile.FileSize === 'number' ? resultFile.FileSize : data.length, + }; +} + +async function sendOrThrow(request: HttpRequest): Promise { + try { + const response = await httpClient.sendRequest(request); + return response.body; + } catch (error) { + throw new Error(describeError(error)); + } +} + +function remainingMs({ deadline, cap }: { deadline: number; cap: number }): number { + const left = deadline - Date.now(); + if (left <= 0) { + throw new Error('ConvertAPI took too long: there is not enough time left in this step to finish. Try smaller files or fewer pages.'); + } + return Math.min(cap, left); +} + +function isConvertApiUrl(url: string): boolean { + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + return parsed.protocol === 'https:' && (host === 'convertapi.com' || host.endsWith('.convertapi.com')); + } catch { + return false; + } +} + +function statusOf(error: unknown): number | undefined { + if (error instanceof HttpError) { + return error.response.status; + } + return undefined; +} + +function readErrorBody(error: HttpError): ConvertApiErrorBody { + const body = parseBody(error.response.body); + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return typeof body === 'string' && body.length > 0 ? { Message: body } : {}; + } + const code = 'Code' in body ? body.Code : undefined; + const message = 'Message' in body ? body.Message : undefined; + return { + Code: typeof code === 'number' ? code : undefined, + Message: typeof message === 'string' ? message : undefined, + }; +} + +function parseBody(body: unknown): unknown { + if (typeof body !== 'string') { + return body; + } + try { + return JSON.parse(body); + } catch { + return body; + } +} + +function describeError(error: unknown): string { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : String(error); + } + const status = error.response.status; + const { Code, Message } = readErrorBody(error); + const detail = [Message, Code === undefined ? undefined : `code ${Code}`] + .filter((part) => part !== undefined) + .join(', '); + const base = `ConvertAPI error (HTTP ${status})${detail.length > 0 ? `: ${detail}` : ''}`; + if (status === 401) { + return `${base}. Check that your API token is active.`; + } + if (status === 403) { + return `${base}. Your ConvertAPI account may be out of conversions.`; + } + if (status === 503) { + return `${base}. ConvertAPI limits how many conversions run at the same time on your plan (the Developer plan allows one). Wait for other conversions to finish, then run the step again.`; + } + return base; +} + +const RESERVED_PARAMETER_NAMES = ['StoreFile', 'Timeout', 'Secret', 'Token']; + +type UploadParams = { + apiKey: string; + file: InputFile; + timeoutMs?: number; +}; + +type ConvertParams = { + apiKey: string; + from: string; + to: string; + parameters: ConvertApiParameter[]; + timeoutMs?: number; +}; + +type TokenCheck = { valid: true } | { valid: false; error: string }; + +export type FileInput = { + name: string; + files: InputFile[]; + multiple: boolean; +}; + +export type ConvertFilesParams = { + apiKey: string; + from: string; + to: string; + fileInputs: FileInput[]; + parameters: ConvertApiParameter[]; + files: FilesService; +}; + +export type ConversionOutput = { + files: StoredFile[]; + conversion_cost: number | null; +}; diff --git a/packages/pieces/community/convertapi/src/lib/common/converters.ts b/packages/pieces/community/convertapi/src/lib/common/converters.ts new file mode 100644 index 000000000000..508df662a335 --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/common/converters.ts @@ -0,0 +1,266 @@ +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { InputProperty, InputPropertyMap, Property } from '@activepieces/pieces-framework'; +import { CONVERTAPI_BASE_URL } from './client'; +import { ConverterInfo, ConverterParameter } from './types'; + +export const converterInfo = { + fetchConverters, + fetchConverter, + parseConverters, + sourceOptions, + destinationOptions, + mainInputOf, + optionProps, +}; + +async function fetchConverters({ from, to }: { from?: string; to?: string } = {}): Promise { + const url = + from === undefined + ? `${CONVERTAPI_BASE_URL}/info` + : `${CONVERTAPI_BASE_URL}/info/${encodeURIComponent(from)}/to/${to === undefined ? '*' : encodeURIComponent(to)}`; + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url, + }); + return parseConverters(response.body); +} + +async function fetchConverter({ from, to }: { from: string; to: string }): Promise { + const converters = await fetchConverters({ from, to }); + const match = converters.find( + (converter) => sourceOf(converter) === from.toLowerCase() && destinationOf(converter) === to.toLowerCase(), + ); + if (match === undefined) { + throw new Error(`ConvertAPI has no converter from "${from}" to "${to}".`); + } + return match; +} + +function parseConverters(body: unknown): ConverterInfo[] { + if (!Array.isArray(body)) { + return []; + } + return body.flatMap((item) => { + const converter = toConverterInfo(item); + return converter === undefined ? [] : [converter]; + }); +} + +function sourceOptions(converters: ConverterInfo[]): DropdownOption[] { + const extensionsBySource = new Map>(); + for (const converter of converters) { + if (mainInputOf(converter) === undefined) { + continue; + } + const source = sourceOf(converter); + const extensions = extensionsBySource.get(source) ?? new Set(); + for (const extension of converter.SourceExtensions ?? []) { + extensions.add(extension.toLowerCase()); + } + extensionsBySource.set(source, extensions); + } + return [...extensionsBySource.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([source, extensions]) => ({ + label: formatLabel({ format: source, extensions: [...extensions] }), + value: source, + })); +} + +function destinationOptions({ converters, from }: { converters: ConverterInfo[]; from: string }): DropdownOption[] { + const seen = new Set(); + return converters + .filter((converter) => sourceOf(converter) === from.toLowerCase() && mainInputOf(converter) !== undefined) + .flatMap((converter) => { + const destination = destinationOf(converter); + if (seen.has(destination)) { + return []; + } + seen.add(destination); + return [{ label: titleOf(converter), value: destination }]; + }) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +function mainInputOf(converter: ConverterInfo): MainInput | undefined { + const parameters = parametersOf(converter); + const single = parameters.find((parameter) => parameter.Name === 'File' && parameter.Type === 'File'); + if (single !== undefined) { + return { name: 'File', multiple: single.Array === true }; + } + const multiple = parameters.find((parameter) => parameter.Name === 'Files' && parameter.Type === 'File'); + if (multiple !== undefined) { + return { name: 'Files', multiple: true }; + } + return undefined; +} + +function optionProps(converter: ConverterInfo): InputPropertyMap { + return Object.fromEntries( + parametersOf(converter).flatMap((parameter) => { + const property = toOptionProperty(parameter); + return property === undefined ? [] : [[parameter.Name, property]]; + }), + ); +} + +function toOptionProperty(parameter: ConverterParameter): InputProperty | undefined { + if (EXCLUDED_PARAMETER_NAMES.has(parameter.Name) || parameter.Type === 'File' || parameter.Array === true) { + return undefined; + } + const shared = { + displayName: parameter.Label ?? parameter.Name, + description: describeParameter(parameter), + required: parameter.Required === true, + }; + const defaultValue = parameter.Default; + switch (parameter.Type) { + case 'Collection': { + const values = parameter.Values ?? {}; + return Property.StaticDropdown({ + ...shared, + defaultValue: typeof defaultValue === 'string' ? defaultValue : undefined, + options: { + options: Object.entries(values).map(([value, label]) => ({ label, value })), + }, + }); + } + case 'Integer': + case 'Double': + return Property.Number({ + ...shared, + defaultValue: typeof defaultValue === 'number' ? defaultValue : undefined, + }); + case 'Bool': + return Property.Checkbox({ + ...shared, + defaultValue: typeof defaultValue === 'boolean' ? defaultValue : undefined, + }); + default: + return Property.ShortText({ + ...shared, + defaultValue: typeof defaultValue === 'string' ? defaultValue : undefined, + }); + } +} + +function describeParameter(parameter: ConverterParameter): string | undefined { + const description = parameter.Description?.replace(/\s+/g, ' ').trim(); + if (description === undefined || description.length === 0) { + return undefined; + } + return description; +} + +function parametersOf(converter: ConverterInfo): ConverterParameter[] { + return (converter.ConverterParameterGroups ?? []) + .filter((group) => group.Name !== 'Authentication') + .flatMap((group) => group.ConverterParameters); +} + +function sourceOf(converter: ConverterInfo): string { + return (converter.SourceFileFormats[0] ?? '').toLowerCase(); +} + +function destinationOf(converter: ConverterInfo): string { + return (converter.DestinationFileFormats[0] ?? '').toLowerCase(); +} + +function titleOf(converter: ConverterInfo): string { + const title = converter.Title?.replace(/\s+API$/i, '').trim(); + if (title !== undefined && title.length > 0) { + return title; + } + return `${sourceOf(converter).toUpperCase()} to ${destinationOf(converter).toUpperCase()}`; +} + +function formatLabel({ format, extensions }: { format: string; extensions: string[] }): string { + const others = extensions.filter((extension) => extension !== format).sort(); + if (others.length === 0) { + return format.toUpperCase(); + } + return `${format.toUpperCase()} (${[format, ...others].map((extension) => `.${extension}`).join(', ')})`; +} + +function toConverterInfo(item: unknown): ConverterInfo | undefined { + if (!isRecord(item)) { + return undefined; + } + const sources = toStringArray(item['SourceFileFormats']); + const destinations = toStringArray(item['DestinationFileFormats']); + if (sources.length === 0 || destinations.length === 0) { + return undefined; + } + const groups = Array.isArray(item['ConverterParameterGroups']) ? item['ConverterParameterGroups'] : []; + return { + Name: typeof item['Name'] === 'string' ? item['Name'] : '', + Title: typeof item['Title'] === 'string' ? item['Title'] : undefined, + SourceFileFormats: sources, + SourceExtensions: toStringArray(item['SourceExtensions']), + DestinationFileFormats: destinations, + DestinationExtensions: toStringArray(item['DestinationExtensions']), + ConverterParameterGroups: groups.flatMap((group) => { + if (!isRecord(group) || typeof group['Name'] !== 'string') { + return []; + } + const parameters = Array.isArray(group['ConverterParameters']) ? group['ConverterParameters'] : []; + return [ + { + Name: group['Name'], + ConverterParameters: parameters.flatMap((parameter) => { + const parsed = toConverterParameter(parameter); + return parsed === undefined ? [] : [parsed]; + }), + }, + ]; + }), + }; +} + +function toConverterParameter(item: unknown): ConverterParameter | undefined { + if (!isRecord(item) || typeof item['Name'] !== 'string' || typeof item['Type'] !== 'string') { + return undefined; + } + const rawDefault = item['Default']; + const rawValues = item['Values']; + return { + Name: item['Name'], + Type: item['Type'], + Label: typeof item['Label'] === 'string' ? item['Label'] : undefined, + Description: typeof item['Description'] === 'string' ? item['Description'] : undefined, + Required: item['Required'] === true, + Array: item['Array'] === true, + Default: + typeof rawDefault === 'string' || typeof rawDefault === 'number' || typeof rawDefault === 'boolean' + ? rawDefault + : undefined, + Values: isRecord(rawValues) + ? Object.fromEntries( + Object.entries(rawValues).flatMap(([key, value]) => (typeof value === 'string' ? [[key, value]] : [])), + ) + : undefined, + }; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +const EXCLUDED_PARAMETER_NAMES = new Set(['File', 'Files', 'StoreFile', 'Timeout', 'Secret', 'Token']); + +type DropdownOption = { + label: string; + value: string; +}; + +export type MainInput = { + name: 'File' | 'Files'; + multiple: boolean; +}; diff --git a/packages/pieces/community/convertapi/src/lib/common/input-files.ts b/packages/pieces/community/convertapi/src/lib/common/input-files.ts new file mode 100644 index 000000000000..859abcbf6dd8 --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/common/input-files.ts @@ -0,0 +1,39 @@ +import { InputFile } from './types'; + +export const inputFiles = { + fromValue, + fromArrayItems, +}; + +function fromValue(value: unknown): InputFile | undefined { + if (!isRecord(value)) { + return undefined; + } + const filename = value['filename']; + const data = value['data']; + if (typeof filename !== 'string' || !Buffer.isBuffer(data)) { + return undefined; + } + return { filename, data }; +} + +function fromArrayItems({ items, key }: { items: unknown; key: string }): InputFile[] { + if (!Array.isArray(items)) { + return []; + } + return items.flatMap((item, index) => { + if (!isRecord(item) || item[key] === undefined || item[key] === null || item[key] === '') { + return []; + } + const file = fromValue(item[key]); + if (file === undefined) { + throw new Error(`File ${index + 1} could not be read. Pick a file from a previous step or upload one.`); + } + return [file]; + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + diff --git a/packages/pieces/community/convertapi/src/lib/common/types.ts b/packages/pieces/community/convertapi/src/lib/common/types.ts new file mode 100644 index 000000000000..71aec4e928cf --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/common/types.ts @@ -0,0 +1,76 @@ +export type ConvertApiFileRef = { + Id: string; +}; + +export type ConvertApiParameter = { + Name: string; + Value?: string; + FileValue?: ConvertApiFileRef; + FileValues?: ConvertApiFileRef[]; +}; + +export type ConvertApiUploadResponse = { + FileId: string; + FileName?: string; + FileExt?: string; + FileSize?: number; + Url?: string; +}; + +export type ConvertApiResultFile = { + FileName: string; + FileExt?: string; + FileSize?: number; + FileId?: string; + Url?: string; + FileUrl?: string; +}; + +export type ConvertApiConversionResponse = { + ConversionCost?: number; + Files?: ConvertApiResultFile[]; +}; + +export type ConvertApiErrorBody = { + Code?: number; + Message?: string; +}; + +export type ConverterParameter = { + Name: string; + Label?: string; + Description?: string; + Type: string; + Required?: boolean; + Array?: boolean; + Default?: string | number | boolean | null; + Values?: Record; + GroupName?: string; +}; + +export type ConverterParameterGroup = { + Name: string; + ConverterParameters: ConverterParameter[]; +}; + +export type ConverterInfo = { + Name: string; + Title?: string; + SourceFileFormats: string[]; + SourceExtensions?: string[]; + DestinationFileFormats: string[]; + DestinationExtensions?: string[]; + ConverterParameterGroups?: ConverterParameterGroup[]; +}; + +export type InputFile = { + filename: string; + data: Buffer; +}; + +export type StoredFile = { + file: string; + file_name: string; + file_extension: string | null; + file_size: number | null; +}; diff --git a/packages/pieces/community/convertapi/src/lib/output-schemas.ts b/packages/pieces/community/convertapi/src/lib/output-schemas.ts new file mode 100644 index 000000000000..813329639b57 --- /dev/null +++ b/packages/pieces/community/convertapi/src/lib/output-schemas.ts @@ -0,0 +1,68 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const mergePdfOutputSchema: OutputSchema = { + fields: [ + ...storedFileFields(), + { + key: 'conversion_cost', + label: 'Conversion Cost', + format: 'number', + description: 'How many ConvertAPI conversions this merge used. Empty when ConvertAPI does not report it.', + }, + ], +}; + +export const splitPdfOutputSchema: OutputSchema = { + itemLabel: '{file_name}', + fields: [ + { + key: 'parts', + label: 'PDF Parts', + value: '', + labelKey: 'file_name', + description: 'One stored PDF per part, in page order.', + listItems: storedFileFields(), + }, + ], +}; + +export const convertFileOutputSchema: OutputSchema = { + itemLabel: '{file_name}', + fields: [ + { + key: 'files', + label: 'Converted Files', + value: '', + labelKey: 'file_name', + description: 'One stored file per output. Most conversions produce a single file; some, like PDF to JPG, produce one per page.', + listItems: storedFileFields(), + }, + ], +}; + +function storedFileFields(): OutputSchema['fields'] { + return [ + { + key: 'file', + label: 'File', + format: 'url', + description: 'The converted file, stored in Activepieces. Pass this to any step that takes a file input.', + }, + { + key: 'file_name', + label: 'File Name', + description: 'Name of the output file, including its extension.', + }, + { + key: 'file_extension', + label: 'File Extension', + description: 'Extension of the output file without the dot, such as pdf or jpg. Empty when ConvertAPI does not report one.', + }, + { + key: 'file_size', + label: 'File Size', + format: 'filesize', + description: 'Size of the output file in bytes.', + }, + ]; +} diff --git a/packages/pieces/community/convertapi/test/actions.test.ts b/packages/pieces/community/convertapi/test/actions.test.ts new file mode 100644 index 000000000000..a798e39924f6 --- /dev/null +++ b/packages/pieces/community/convertapi/test/actions.test.ts @@ -0,0 +1,875 @@ +import { HttpError } from '@activepieces/pieces-common'; +import { + ApFile, + AppConnectionType, + createMockActionContext, + InputPropertyMap, + PropertyContext, + StaticPropsValue, +} from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { convertFileAction } from '../src/lib/actions/convert-file'; +import { mergePdfAction } from '../src/lib/actions/merge-pdf'; +import { splitPdfAction } from '../src/lib/actions/split-pdf'; +import { convertApiAuth } from '../src/lib/auth'; + +const sendRequest = vi.fn(); + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + httpClient: { + sendRequest: (...args: unknown[]) => sendRequest(...args), + }, + }; +}); + +const AUTH = { type: AppConnectionType.SECRET_TEXT, secret_text: 'token_123' } satisfies AuthValue; +const BASE = 'https://v2.convertapi.com'; + +function pdf(name: string): ApFile { + return new ApFile(name, Buffer.from(`%PDF ${name}`)); +} + +function resultFile({ name, id }: { name: string; id: string }) { + return { + FileName: name, + FileExt: name.split('.').pop(), + FileSize: 1000, + FileId: id, + Url: `${BASE}/d/${id}/${name}`, + }; +} + +function mockApi({ conversion, info }: { conversion?: ConversionReply; info?: unknown } = {}) { + let uploads = 0; + sendRequest.mockImplementation(async (request: Request) => { + if (request.url === `${BASE}/upload`) { + uploads += 1; + return { status: 200, body: { FileId: `up${uploads}`, FileName: request.queryParams?.['filename'] } }; + } + if (request.url.startsWith(`${BASE}/convert/`)) { + const reply = conversion ?? { body: { ConversionCost: 1, Files: [resultFile({ name: 'out.pdf', id: 'res1' })] } }; + if (reply.status !== undefined && reply.status >= 400) { + throw new HttpError(request.body, { status: reply.status, responseBody: reply.body }); + } + return { status: 200, body: reply.body }; + } + if (request.url.startsWith(`${BASE}/info`)) { + return { status: 200, body: info ?? [] }; + } + if (request.method === 'GET' && request.url.startsWith(`${BASE}/d/`)) { + return { status: 200, body: Buffer.from(`bytes of ${request.url}`) }; + } + if (request.method === 'DELETE') { + return { status: 200, body: undefined }; + } + throw new Error(`Unexpected request ${request.method} ${request.url}`); + }); +} + +function requests(): Request[] { + return sendRequest.mock.calls.map((call) => call[0]); +} + +function conversionRequest(): Request { + const found = requests().find((request) => request.url.startsWith(`${BASE}/convert/`)); + if (found === undefined) { + throw new Error('no conversion request'); + } + return found; +} + +function parametersOf(request: Request): Record[] { + const body = request.body; + if (typeof body !== 'object' || body === null || !('Parameters' in body) || !Array.isArray(body.Parameters)) { + throw new Error('no Parameters'); + } + return body.Parameters; +} + +function param({ request, name }: { request: Request; name: string }) { + return parametersOf(request).find((parameter) => parameter['Name'] === name); +} + +function makeFiles() { + return { + write: vi.fn(async ({ fileName }: { fileName: string; data: unknown }) => `https://ap.files/${fileName}`), + upload: vi.fn(async ({ fileName }: { fileName: string; data: unknown }) => ({ id: fileName, url: `https://ap.files/${fileName}` })), + }; +} + +function makeContext({ + propsValue, + files, +}: { + propsValue: StaticPropsValue; + files?: ReturnType; +}) { + return { ...createMockActionContext({ propsValue }), auth: AUTH, files: files ?? makeFiles() }; +} + +function propertyContext(): PropertyContext { + const { server, project, flows, connections } = createMockActionContext({ propsValue: {} }); + return { server, project, flows, connections }; +} + +async function runMerge({ propsValue, files }: RunInput) { + const output = await mergePdfAction.run( + makeContext({ + propsValue: { + fileName: undefined, + password: undefined, + bookmarksTableOfContents: undefined, + pageSize: undefined, + pageOrientation: undefined, + ...propsValue, + }, + files, + }), + ); + return expectStoredOutput(output); +} + +async function runSplit({ propsValue, files }: RunInput) { + const output = await splitPdfAction.run( + makeContext({ + propsValue: { + valueHelp: undefined, + value: undefined, + mergeOutput: undefined, + password: undefined, + fileName: undefined, + ...propsValue, + }, + files, + }), + ); + return expectStoredOutputs(output); +} + +async function runConvert({ propsValue, files }: RunInput) { + const output = await convertFileAction.run( + makeContext({ propsValue: { additionalFiles: undefined, options: undefined, ...propsValue }, files }), + ); + return expectStoredOutputs(output); +} + +function isStoredOutput(value: unknown): value is StoredOutput { + return ( + typeof value === 'object' && + value !== null && + 'file' in value && + typeof value.file === 'string' && + 'file_name' in value && + typeof value.file_name === 'string' + ); +} + +function expectStoredOutput(value: unknown): StoredOutput { + if (!isStoredOutput(value)) { + throw new Error('expected a stored file output'); + } + return value; +} + +function expectStoredOutputs(value: unknown): StoredOutput[] { + if (!Array.isArray(value) || !value.every(isStoredOutput)) { + throw new Error('expected a list of stored file outputs'); + } + return value; +} + +function validateToken({ auth }: { auth: string }) { + return convertApiAuth.validate?.({ + auth, + server: { apiUrl: 'http://localhost:3000', publicUrl: 'http://localhost:4200', mintOidcToken: async () => 'oidc' }, + }); +} + +const DOCX_TO_PDF = { + Name: 'Word', + Title: 'DOCX to PDF API', + SourceFileFormats: ['docx'], + SourceExtensions: ['doc', 'docx'], + DestinationFileFormats: ['pdf'], + DestinationExtensions: ['pdf'], + ConverterParameterGroups: [ + { + Name: 'Authentication', + ConverterParameters: [{ Name: 'Token', Type: 'String', Required: false, Array: false }], + }, + { + Name: 'Input', + ConverterParameters: [ + { Name: 'File', Type: 'File', Required: true, Array: false }, + { Name: 'PageRange', Label: 'Page range', Type: 'String', Required: false, Array: false, Default: '1-2000' }, + ], + }, + { + Name: 'Output', + ConverterParameters: [ + { Name: 'FileName', Label: 'Output file name', Type: 'String', Required: false, Array: false }, + { Name: 'StoreFile', Type: 'Bool', Required: false, Array: false, Default: false }, + ], + }, + { + Name: 'Execution', + ConverterParameters: [{ Name: 'Timeout', Type: 'Integer', Required: false, Array: false, Default: 900 }], + }, + { + Name: 'PDF', + ConverterParameters: [ + { Name: 'PdfVersion', Label: 'PDF version', Type: 'Collection', Required: false, Array: false, Default: '1.7', Values: { '1.4': 'PDF 1.4', '1.7': 'PDF 1.7' } }, + { Name: 'ConvertMarkups', Label: 'Convert markups', Type: 'Bool', Required: false, Array: false, Default: false }, + { Name: 'ImageQuality', Label: 'Image quality', Type: 'Integer', Required: false, Array: false, Default: 75 }, + { Name: 'Scale', Label: 'Scale', Type: 'Double', Required: false, Array: false }, + { Name: 'TemplateFile', Label: 'Template', Type: 'File', Required: false, Array: false }, + ], + }, + ], +}; + +const DOCX_MERGE = { + Name: 'Word', + Title: 'Merge DOCX API', + SourceFileFormats: ['docx'], + SourceExtensions: ['docx'], + DestinationFileFormats: ['merge'], + ConverterParameterGroups: [ + { Name: 'Input', ConverterParameters: [{ Name: 'Files', Type: 'File', Required: true, Array: true }] }, + ], +}; + +const PDF_TO_JPG = { + Name: 'Pdf', + Title: 'PDF to JPG API', + SourceFileFormats: ['pdf'], + SourceExtensions: ['pdf'], + DestinationFileFormats: ['jpg'], + ConverterParameterGroups: [ + { Name: 'Input', ConverterParameters: [{ Name: 'File', Type: 'File', Required: true, Array: false }] }, + ], +}; + +const DATA_TO_QR = { + Name: 'Barcode', + Title: 'Data to QR Code API', + SourceFileFormats: ['data'], + DestinationFileFormats: ['qrcode'], + ConverterParameterGroups: [ + { Name: 'Input', ConverterParameters: [{ Name: 'Data', Type: 'String', Required: true, Array: false }] }, + ], +}; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('Merge PDF Files', () => { + it('uploads every file as raw bytes, then converts by file id', async () => { + mockApi(); + + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + const uploads = requests().filter((request) => request.url === `${BASE}/upload`); + expect(uploads).toHaveLength(2); + expect(uploads[0].method).toBe('POST'); + expect(uploads[0].headers?.['Content-Type']).toBe('application/octet-stream'); + expect(uploads[0].headers?.['Authorization']).toBe('Bearer token_123'); + expect(uploads[0].queryParams).toEqual({ filename: 'a.pdf' }); + expect(Buffer.isBuffer(uploads[0].body)).toBe(true); + + const conversion = conversionRequest(); + expect(conversion.url).toBe(`${BASE}/convert/pdf/to/merge`); + expect(conversion.headers?.['Authorization']).toBe('Bearer token_123'); + expect(param({ request: conversion, name: 'Files' })).toEqual({ Name: 'Files', FileValues: [{ Id: 'up1' }, { Id: 'up2' }] }); + }); + + it('always asks for stored files and a timeout under the step limit', async () => { + mockApi(); + + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + const conversion = conversionRequest(); + expect(param({ request: conversion, name: 'StoreFile' })).toEqual({ Name: 'StoreFile', Value: 'true' }); + expect(param({ request: conversion, name: 'Timeout' })).toEqual({ Name: 'Timeout', Value: '360' }); + expect(Number(param({ request: conversion, name: 'Timeout' })?.['Value'])).toBeLessThanOrEqual(360); + }); + + it('sends the merge options as string values and skips empty ones', async () => { + mockApi(); + + await runMerge({ + propsValue: { + files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }], + fileName: 'combined', + password: 'secret', + bookmarksTableOfContents: 'filename', + pageSize: 'a4', + pageOrientation: 'landscape', + }, + }); + + const conversion = conversionRequest(); + expect(param({ request: conversion, name: 'FileName' })).toEqual({ Name: 'FileName', Value: 'combined' }); + expect(param({ request: conversion, name: 'Password' })).toEqual({ Name: 'Password', Value: 'secret' }); + expect(param({ request: conversion, name: 'BookmarksTableOfContents' })).toEqual({ Name: 'BookmarksTableOfContents', Value: 'filename' }); + expect(param({ request: conversion, name: 'PageSize' })).toEqual({ Name: 'PageSize', Value: 'a4' }); + expect(param({ request: conversion, name: 'PageOrientation' })).toEqual({ Name: 'PageOrientation', Value: 'landscape' }); + + sendRequest.mockClear(); + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }], fileName: '' } }); + expect(param({ request: conversionRequest(), name: 'FileName' })).toBeUndefined(); + expect(param({ request: conversionRequest(), name: 'Password' })).toBeUndefined(); + }); + + it('downloads the result URL and writes it as an Activepieces file', async () => { + mockApi({ conversion: { body: { ConversionCost: 2, Files: [resultFile({ name: 'merged.pdf', id: 'res1' })] } } }); + const files = makeFiles(); + + const result = await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] }, files }); + + const download = requests().find((request) => request.method === 'GET' && request.url.includes('/d/res1')); + expect(download?.url).toBe(`${BASE}/d/res1/merged.pdf`); + expect(download?.responseType).toBe('arraybuffer'); + expect(download?.headers).toBeUndefined(); + expect(files.write).toHaveBeenCalledTimes(1); + expect(files.write.mock.calls[0][0].fileName).toBe('merged.pdf'); + expect(Buffer.isBuffer(files.write.mock.calls[0][0].data)).toBe(true); + expect(result).toEqual({ + file: 'https://ap.files/merged.pdf', + file_name: 'merged.pdf', + file_extension: 'pdf', + file_size: 1000, + conversion_cost: 2, + }); + expect(JSON.stringify(result)).not.toContain('FileData'); + }); + + it('accepts the FileUrl field some responses use instead of Url', async () => { + mockApi({ + conversion: { body: { Files: [{ FileName: 'merged.pdf', FileExt: 'pdf', FileUrl: `${BASE}/d/alt/merged.pdf` }] } }, + }); + + const result = await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + expect(result.file).toBe('https://ap.files/merged.pdf'); + expect(result.conversion_cost).toBeNull(); + }); + + it('fails clearly when ConvertAPI returns no download link instead of passing base64 through', async () => { + mockApi({ conversion: { body: { Files: [{ FileName: 'merged.pdf', FileData: 'JVBERi0x' }] } } }); + + await expect(runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } })).rejects.toThrow( + /did not return a download link/, + ); + }); + + it('downloads results without following redirects and with a bounded timeout', async () => { + mockApi(); + + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + const download = requests().find((request) => request.method === 'GET' && request.url.includes('/d/res1')); + expect(download?.followRedirects).toBe(false); + expect(download?.timeout).toBe(60_000); + }); + + it('refuses to download a result link outside convertapi.com', async () => { + mockApi({ conversion: { body: { Files: [{ FileName: 'merged.pdf', FileExt: 'pdf', Url: 'http://169.254.169.254/latest/meta-data' }] } } }); + + await expect(runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } })).rejects.toThrow( + /outside convertapi\.com/, + ); + expect(requests().some((request) => request.method === 'GET' && request.url.includes('169.254'))).toBe(false); + }); + + it('keeps the conversion inside the step time limit', async () => { + mockApi(); + + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + expect(conversionRequest().timeout).toBe(390_000); + }); + + it('downloads many result files in parallel, each bounded by the step budget', async () => { + const many = Array.from({ length: 5 }, (_, index) => resultFile({ name: `page-${index + 1}.jpg`, id: `r${index + 1}` })); + mockApi({ conversion: { body: { Files: many } } }); + + const result = await runSplit({ propsValue: { file: pdf('a.pdf'), mode: 'singlepages', value: '1,2,3,4,5' } }); + + expect(result).toHaveLength(5); + const downloads = requests().filter((request) => request.method === 'GET' && request.url.includes('/d/r')); + expect(downloads).toHaveLength(5); + for (const download of downloads) { + expect(download.timeout).toBeGreaterThan(0); + expect(download.timeout).toBeLessThanOrEqual(60_000); + } + }); + + it('waits for every result download to finish before cleaning up, even when one fails', async () => { + mockApi({ + conversion: { + body: { Files: [resultFile({ name: 'part-1.pdf', id: 'r1' }), resultFile({ name: 'part-2.pdf', id: 'r2' })] }, + }, + }); + const base = sendRequest.getMockImplementation(); + const events: string[] = []; + sendRequest.mockImplementation(async (request: Request) => { + if (request.method === 'GET' && request.url.includes('/d/r1')) { + events.push('r1 download failed'); + throw new HttpError(undefined, { status: 500, responseBody: { Message: 'broken' } }); + } + if (request.method === 'GET' && request.url.includes('/d/r2')) { + await new Promise((resolve) => setTimeout(resolve, 30)); + events.push('r2 download finished'); + } + if (request.method === 'DELETE') { + events.push(`delete ${request.url.split('/d/')[1]}`); + } + return base === undefined ? undefined : base(request); + }); + + await expect(runSplit({ propsValue: { file: pdf('a.pdf'), mode: 'singlepages', value: '1,2' } })).rejects.toThrow(); + + const finished = events.indexOf('r2 download finished'); + const firstDelete = events.findIndex((event) => event.startsWith('delete')); + expect(finished).toBeGreaterThanOrEqual(0); + expect(firstDelete).toBeGreaterThan(finished); + }); + + it('stops with a clear error instead of overrunning the step when the budget is spent', async () => { + mockApi(); + const start = Date.now(); + let calls = 0; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => { + calls += 1; + return calls <= 4 ? start : start + 600_000; + }); + + await expect(runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } })).rejects.toThrow( + /not enough time left/, + ); + expect(requests().some((request) => request.method === 'GET' && request.url.includes('/d/res1'))).toBe(false); + clock.mockRestore(); + }); + + it('still returns the files but warns when ConvertAPI cleanup fails', async () => { + mockApi(); + const base = sendRequest.getMockImplementation(); + sendRequest.mockImplementation(async (request: Request) => { + if (request.method === 'DELETE' && request.url === `${BASE}/d/up1`) { + throw new HttpError(undefined, { status: 500, responseBody: { Message: 'nope' } }); + } + return base === undefined ? undefined : base(request); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const result = await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + expect(result.file_name).toBe('out.pdf'); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('up1'); + warn.mockRestore(); + }); + + it('deletes the uploaded inputs and the stored result from ConvertAPI afterwards', async () => { + mockApi(); + + await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + const deleted = requests() + .filter((request) => request.method === 'DELETE') + .map((request) => request.url); + expect(deleted.sort()).toEqual([`${BASE}/d/res1`, `${BASE}/d/up1`, `${BASE}/d/up2`]); + }); + + it('needs at least two files before calling ConvertAPI', async () => { + await expect(runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }] } })).rejects.toThrow(/at least two/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('Split PDF File', () => { + it.each([ + { mode: 'pagecount', value: '5', expected: '5' }, + { mode: 'pagecount', value: '3,2', expected: '3,2' }, + { mode: 'ranges', value: '1-3,5,7-9', expected: '1-3,5,7-9' }, + { mode: 'singlepages', value: '1-2,5', expected: '1-2,5' }, + { mode: 'text', value: 'Invoice\\s+#\\d+', expected: 'Invoice\\s+#\\d+' }, + ])('sends Mode=$mode with Value=$value', async ({ mode, value, expected }) => { + mockApi(); + + await runSplit({ propsValue: { file: pdf('doc.pdf'), mode, value } }); + + const conversion = conversionRequest(); + expect(conversion.url).toBe(`${BASE}/convert/pdf/to/split`); + expect(param({ request: conversion, name: 'File' })).toEqual({ Name: 'File', FileValue: { Id: 'up1' } }); + expect(param({ request: conversion, name: 'Mode' })).toEqual({ Name: 'Mode', Value: mode }); + expect(param({ request: conversion, name: 'Value' })).toEqual({ Name: 'Value', Value: expected }); + }); + + it('omits Value for page count when empty, giving one file per page', async () => { + mockApi(); + + await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount', value: '' } }); + + expect(param({ request: conversionRequest(), name: 'Mode' })).toEqual({ Name: 'Mode', Value: 'pagecount' }); + expect(param({ request: conversionRequest(), name: 'Value' })).toBeUndefined(); + }); + + it('ignores Value for the bookmark mode', async () => { + mockApi(); + + await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'bookmark', value: 'anything' } }); + + expect(param({ request: conversionRequest(), name: 'Mode' })).toEqual({ Name: 'Mode', Value: 'bookmark' }); + expect(param({ request: conversionRequest(), name: 'Value' })).toBeUndefined(); + }); + + it.each(['ranges', 'singlepages', 'text'])('requires a Value for the %s mode', async (mode) => { + await expect(runSplit({ propsValue: { file: pdf('doc.pdf'), mode, value: ' ' } })).rejects.toThrow(/Split Value/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('sends MergeOutput and Password only when set', async () => { + mockApi(); + + await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'ranges', value: '1,3', mergeOutput: true, password: 'pw' } }); + expect(param({ request: conversionRequest(), name: 'MergeOutput' })).toEqual({ Name: 'MergeOutput', Value: 'true' }); + expect(param({ request: conversionRequest(), name: 'Password' })).toEqual({ Name: 'Password', Value: 'pw' }); + + sendRequest.mockClear(); + await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'ranges', value: '1,3', mergeOutput: false } }); + expect(param({ request: conversionRequest(), name: 'MergeOutput' })).toBeUndefined(); + expect(param({ request: conversionRequest(), name: 'Password' })).toBeUndefined(); + }); + + it('returns one stored file per part, as an array', async () => { + mockApi({ + conversion: { + body: { + ConversionCost: 1, + Files: [resultFile({ name: 'doc_0.pdf', id: 'r0' }), resultFile({ name: 'doc_1.pdf', id: 'r1' }), resultFile({ name: 'doc_2.pdf', id: 'r2' })], + }, + }, + }); + const files = makeFiles(); + + const result = await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount', value: '1' }, files }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(files.write).toHaveBeenCalledTimes(3); + expect(result.map((part) => part.file)).toEqual([ + 'https://ap.files/doc_0.pdf', + 'https://ap.files/doc_1.pdf', + 'https://ap.files/doc_2.pdf', + ]); + expect(Object.keys(result[0]).sort()).toEqual(['file', 'file_extension', 'file_name', 'file_size']); + }); +}); + +describe('error surfacing', () => { + it('shows ConvertAPI\'s message for a concurrency limit and does not retry', async () => { + mockApi({ + conversion: { status: 503, body: { Code: 5030, Message: 'Too many parallel conversions.' } }, + }); + + const error = await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount' } }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(Error); + const message = error instanceof Error ? error.message : ''; + expect(message).toContain('Too many parallel conversions.'); + expect(message).toContain('code 5030'); + expect(message).toContain('HTTP 503'); + expect(message).toMatch(/one/); + const conversions = requests().filter((request) => request.url.startsWith(`${BASE}/convert/`)); + expect(conversions).toHaveLength(1); + expect(conversions[0].retries).toBeUndefined(); + }); + + it('still cleans up uploaded files when the conversion fails', async () => { + mockApi({ conversion: { status: 500, body: { Code: 5002, Message: 'The file is damaged.' } } }); + + await expect(runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount' } })).rejects.toThrow(/The file is damaged/); + + expect(requests().some((request) => request.method === 'DELETE' && request.url === `${BASE}/d/up1`)).toBe(true); + }); + + it('reads error bodies that arrive as JSON strings', async () => { + mockApi({ conversion: { status: 401, body: '{"Code":4011,"Message":"Unauthorized."}' } }); + + await expect(runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount' } })).rejects.toThrow( + /Unauthorized\., code 4011\. Check that your API token is active/, + ); + }); +}); + +describe('Convert File', () => { + it('looks up the converter, uploads the file and converts it', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + const result = await runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('letter.docx'), options: {} } }); + + expect(requests()[0].url).toBe(`${BASE}/info/docx/to/pdf`); + const conversion = conversionRequest(); + expect(conversion.url).toBe(`${BASE}/convert/docx/to/pdf`); + expect(param({ request: conversion, name: 'File' })).toEqual({ Name: 'File', FileValue: { Id: 'up1' } }); + expect(result).toEqual([ + { file: 'https://ap.files/out.pdf', file_name: 'out.pdf', file_extension: 'pdf', file_size: 1000 }, + ]); + }); + + it('passes the converter options but never lets them override StoreFile or Timeout', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + await runConvert({ + propsValue: { + from: 'docx', + to: 'pdf', + file: pdf('letter.docx'), + options: { PageRange: '1-3', ConvertMarkups: true, ImageQuality: 80, Timeout: 900, StoreFile: false, Empty: '' }, + }, + }); + + const conversion = conversionRequest(); + expect(param({ request: conversion, name: 'PageRange' })).toEqual({ Name: 'PageRange', Value: '1-3' }); + expect(param({ request: conversion, name: 'ConvertMarkups' })).toEqual({ Name: 'ConvertMarkups', Value: 'true' }); + expect(param({ request: conversion, name: 'ImageQuality' })).toEqual({ Name: 'ImageQuality', Value: '80' }); + expect(param({ request: conversion, name: 'Empty' })).toBeUndefined(); + const timeouts = parametersOf(conversion).filter((parameter) => parameter['Name'] === 'Timeout'); + expect(timeouts).toEqual([{ Name: 'Timeout', Value: '360' }]); + const storeFiles = parametersOf(conversion).filter((parameter) => parameter['Name'] === 'StoreFile'); + expect(storeFiles).toEqual([{ Name: 'StoreFile', Value: 'true' }]); + }); + + it('names the output "converted" when the input came from a URL with no file name', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + await runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('unknown.docx'), options: {} } }); + + expect(param({ request: conversionRequest(), name: 'FileName' })).toEqual({ Name: 'FileName', Value: 'converted' }); + }); + + it('keeps the input name, or the FileName option, when there is one', async () => { + mockApi({ info: [DOCX_TO_PDF, DOCX_TO_PDF] }); + + await runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('letter.docx'), options: {} } }); + expect(param({ request: conversionRequest(), name: 'FileName' })).toBeUndefined(); + + await runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('unknown.docx'), options: { FileName: 'invoice' } } }); + const conversions = requests().filter((request) => request.url.includes('/convert/')); + expect(param({ request: conversions[conversions.length - 1], name: 'FileName' })).toEqual({ Name: 'FileName', Value: 'invoice' }); + }); + + it('sends every file as Files for converters that combine files', async () => { + mockApi({ info: [DOCX_MERGE] }); + + await runConvert({ + propsValue: { + from: 'docx', + to: 'merge', + file: pdf('one.docx'), + additionalFiles: [{ file: pdf('two.docx') }, { file: pdf('three.docx') }], + }, + }); + + expect(param({ request: conversionRequest(), name: 'Files' })).toEqual({ + Name: 'Files', + FileValues: [{ Id: 'up1' }, { Id: 'up2' }, { Id: 'up3' }], + }); + }); + + it('rejects additional files for single-file converters before uploading', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + await expect( + runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('a.docx'), additionalFiles: [{ file: pdf('b.docx') }] } }), + ).rejects.toThrow(/takes a single file/); + expect(requests().some((request) => request.url === `${BASE}/upload`)).toBe(false); + }); + + it('returns every output file when a conversion produces several', async () => { + mockApi({ + info: [PDF_TO_JPG], + conversion: { body: { Files: [resultFile({ name: 'p-1.jpg', id: 'j1' }), resultFile({ name: 'p-2.jpg', id: 'j2' })] } }, + }); + + const result = await runConvert({ propsValue: { from: 'pdf', to: 'jpg', file: pdf('deck.pdf') } }); + + expect(result.map((item) => item.file_name)).toEqual(['p-1.jpg', 'p-2.jpg']); + }); + + it('reports a missing converter', async () => { + mockApi({ info: [] }); + + await expect(runConvert({ propsValue: { from: 'docx', to: 'xyz', file: pdf('a.docx') } })).rejects.toThrow(/no converter/); + }); +}); + +describe('/info dropdowns', () => { + const fromProp = convertFileAction.props.from; + const toProp = convertFileAction.props.to; + const optionsProp = convertFileAction.props.options; + + it('lists each source format once with its extensions, skipping converters without a file input', async () => { + mockApi({ info: [DOCX_TO_PDF, DOCX_MERGE, PDF_TO_JPG, DATA_TO_QR, { Name: 'broken' }] }); + + const result = await fromProp.options({ auth: AUTH }, propertyContext()); + + expect(requests()[0].url).toBe(`${BASE}/info`); + expect(result.options).toEqual([ + { label: 'DOCX (.docx, .doc)', value: 'docx' }, + { label: 'PDF', value: 'pdf' }, + ]); + }); + + it('asks for a From Format before listing targets', async () => { + const result = await toProp.options({ auth: AUTH }, propertyContext()); + + expect(result.disabled).toBe(true); + expect(result.placeholder).toMatch(/From Format/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('lists the targets for the chosen source using the converter titles', async () => { + mockApi({ info: [DOCX_TO_PDF, DOCX_MERGE] }); + + const result = await toProp.options({ auth: AUTH, from: 'docx' }, propertyContext()); + + expect(requests()[0].url).toBe(`${BASE}/info/docx/to/*`); + expect(result.options).toEqual([ + { label: 'DOCX to PDF', value: 'pdf' }, + { label: 'Merge DOCX', value: 'merge' }, + ]); + }); + + it('builds option fields from the converter parameters', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + const props = await optionsProp.props({ auth: AUTH, from: 'docx', to: 'pdf' }, propertyContext()); + + expect(Object.keys(props).sort()).toEqual(['ConvertMarkups', 'FileName', 'ImageQuality', 'PageRange', 'PdfVersion', 'Scale']); + expect(props['PageRange']).toMatchObject({ type: 'SHORT_TEXT', displayName: 'Page range', defaultValue: '1-2000' }); + expect(props['PdfVersion']).toMatchObject({ + type: 'STATIC_DROPDOWN', + defaultValue: '1.7', + options: { options: [{ label: 'PDF 1.4', value: '1.4' }, { label: 'PDF 1.7', value: '1.7' }] }, + }); + expect(props['ConvertMarkups']).toMatchObject({ type: 'CHECKBOX', defaultValue: false }); + expect(props['ImageQuality']).toMatchObject({ type: 'NUMBER', defaultValue: 75 }); + expect(props['Scale']).toMatchObject({ type: 'NUMBER' }); + }); + + it('returns no option fields until both formats are picked', async () => { + const props = await optionsProp.props({ auth: AUTH, from: 'docx' }, propertyContext()); + + expect(props).toEqual({}); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('auth validation', () => { + + it('treats a 401 as an invalid token', async () => { + sendRequest.mockRejectedValueOnce(new HttpError({}, { status: 401, responseBody: { Code: 4011, Message: 'Unauthorized.' } })); + + const result = await validateToken({ auth: 'bad' }); + + expect(result).toMatchObject({ valid: false }); + expect(sendRequest.mock.calls[0][0].headers.Authorization).toBe('Bearer bad'); + }); + + it('treats a parameter validation error as a working token, without running a conversion', async () => { + sendRequest.mockRejectedValueOnce(new HttpError({}, { status: 400, responseBody: { Code: 4000, Message: 'Parameter validation error.' } })); + + const result = await validateToken({ auth: 'good' }); + + expect(result).toEqual({ valid: true }); + expect(sendRequest.mock.calls[0][0].body).toEqual({ Parameters: [] }); + }); + + it('reports network failures without calling the token invalid', async () => { + sendRequest.mockRejectedValueOnce(new HttpError({}, { status: 502, responseBody: 'Bad gateway' })); + + const result = await validateToken({ auth: 'good' }); + + expect(result).toMatchObject({ valid: false, error: expect.stringMatching(/Could not reach ConvertAPI/) }); + }); +}); + +describe('output schemas', () => { + function fieldKeys(fields: { key: string }[] | undefined): string[] { + return (fields ?? []).map((field) => field.key).sort(); + } + + it('describes the flat merged file with the same keys run() returns', async () => { + mockApi({ conversion: { body: { ConversionCost: 2, Files: [resultFile({ name: 'merged.pdf', id: 'res1' })] } } }); + + const result = await runMerge({ propsValue: { files: [{ file: pdf('a.pdf') }, { file: pdf('b.pdf') }] } }); + + const schema = mergePdfAction.outputSchema; + expect(schema?.itemLabel).toBeUndefined(); + expect(fieldKeys(schema?.fields)).toEqual(Object.keys(result).sort()); + }); + + it('describes each split part with the same keys run() returns', async () => { + mockApi({ conversion: { body: { Files: [resultFile({ name: 'p-1.pdf', id: 'r1' }), resultFile({ name: 'p-2.pdf', id: 'r2' })] } } }); + + const result = await runSplit({ propsValue: { file: pdf('doc.pdf'), mode: 'pagecount', value: '1' } }); + + const schema = splitPdfAction.outputSchema; + expect(schema?.itemLabel).toBe('{file_name}'); + expect(schema?.fields).toHaveLength(1); + expect(schema?.fields[0].value).toBe(''); + expect(fieldKeys(schema?.fields[0].listItems)).toEqual(Object.keys(result[0]).sort()); + }); + + it('describes each converted file with the same keys run() returns', async () => { + mockApi({ info: [DOCX_TO_PDF] }); + + const result = await runConvert({ propsValue: { from: 'docx', to: 'pdf', file: pdf('letter.docx'), options: {} } }); + + const schema = convertFileAction.outputSchema; + expect(schema?.itemLabel).toBe('{file_name}'); + expect(schema?.fields).toHaveLength(1); + expect(schema?.fields[0].value).toBe(''); + expect(fieldKeys(schema?.fields[0].listItems)).toEqual(Object.keys(result[0]).sort()); + }); +}); + +type Request = { + method: string; + url: string; + headers?: Record; + queryParams?: Record; + body?: unknown; + responseType?: string; + retries?: number; +}; + +type ConversionReply = { status?: number; body: unknown }; + +type AuthValue = { type: AppConnectionType.SECRET_TEXT; secret_text: string }; + +type OptionalKeys = { + [Key in keyof Value]-?: undefined extends Value[Key] ? Key : never; +}[keyof Value]; + +type LoosePropsValue = Omit> & Partial>>; + +type RunInput = { + propsValue: LoosePropsValue>; + files?: ReturnType; +}; + +type StoredOutput = { + file: string; + file_name: string; + file_extension?: string | null; + file_size?: number | null; + conversion_cost?: number | null; +}; diff --git a/packages/pieces/community/convertapi/tsconfig.json b/packages/pieces/community/convertapi/tsconfig.json new file mode 100644 index 000000000000..71bc5814f5de --- /dev/null +++ b/packages/pieces/community/convertapi/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/pieces/community/convertapi/tsconfig.lib.json b/packages/pieces/community/convertapi/tsconfig.lib.json new file mode 100644 index 000000000000..a648eb45d7fa --- /dev/null +++ b/packages/pieces/community/convertapi/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/pieces/community/convertapi/vitest.config.ts b/packages/pieces/community/convertapi/vitest.config.ts new file mode 100644 index 000000000000..79c972619047 --- /dev/null +++ b/packages/pieces/community/convertapi/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'), + }, + }, +}) diff --git a/packages/pieces/community/convertkit/package.json b/packages/pieces/community/convertkit/package.json index 9e31c26960df..f45056c90f08 100644 --- a/packages/pieces/community/convertkit/package.json +++ b/packages/pieces/community/convertkit/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-convertkit", - "version": "0.3.9", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "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" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/convertkit/src/i18n/translation.json b/packages/pieces/community/convertkit/src/i18n/translation.json index 6d9e6a2134c6..c0c10b7dee0a 100644 --- a/packages/pieces/community/convertkit/src/i18n/translation.json +++ b/packages/pieces/community/convertkit/src/i18n/translation.json @@ -1,6 +1,6 @@ { "Email marketing for creators": "Email marketing for creators", - "Enter your API Secret key": "Enter your API Secret key", + "Enter your API Secret key. In Kit, go to Settings > Developer, open the V3 Key section and copy the API Secret.": "Enter your API Secret key. In Kit, go to Settings > Developer, open the V3 Key section and copy the API Secret.", "Get Subscriber By Id": "Get Subscriber By Id", "Get Subscriber By Email": "Get Subscriber By Email", "List Subscribers": "List Subscribers", @@ -36,6 +36,20 @@ "Get Purchase By Id": "Get Purchase By Id", "Create Purchase": "Create Purchase", "Create Multiple Purchases": "Create Multiple Purchases", + "Get Account": "Get Account", + "Get Subscriber": "Get Subscriber", + "Remove Tag By Email": "Remove Tag By Email", + "Remove Tag From Subscriber": "Remove Tag From Subscriber", + "List Tag Subscriptions": "List Tag Subscriptions", + "Get Broadcast Stats": "Get Broadcast Stats", + "Update Custom Field": "Update Custom Field", + "Delete Custom Field": "Delete Custom Field", + "Create Webhook": "Create Webhook", + "List Webhooks": "List Webhooks", + "List Sequence Subscriptions": "List Sequence Subscriptions", + "List Subscriber Tags": "List Subscriber Tags", + "Get Purchase": "Get Purchase", + "Custom API Call": "Custom API Call", "Returns data for a single subscriber": "Returns data for a single subscriber", "Returns a list of all subscribers": "Returns a list of all subscribers", "Update a subscriber": "Update a subscriber", @@ -69,6 +83,40 @@ "Returns data for a single purchase": "Returns data for a single purchase", "Creates a new purchase": "Creates a new purchase", "Creates multiple purchases": "Creates multiple purchases", + "Get the name and primary email address of the connected Kit account.": "Get the name and primary email address of the connected Kit account.", + "List or search subscribers, 50 per page.": "List or search subscribers, 50 per page.", + "Get one subscriber by subscriber ID.": "Get one subscriber by subscriber ID.", + "Update the email, first name or custom fields of a subscriber.": "Update the email, first name or custom fields of a subscriber.", + "Unsubscribe an email address from all forms and sequences.": "Unsubscribe an email address from all forms and sequences.", + "List the tags on the account.": "List the tags on the account.", + "Create a tag, or return the existing tag with the same name.": "Create a tag, or return the existing tag with the same name.", + "Apply a tag to a subscriber by email, creating the subscriber if needed.": "Apply a tag to a subscriber by email, creating the subscriber if needed.", + "Remove a tag from a subscriber identified by email.": "Remove a tag from a subscriber identified by email.", + "Remove a tag from a subscriber identified by subscriber ID.": "Remove a tag from a subscriber identified by subscriber ID.", + "List the subscribers who have a tag, 50 per page.": "List the subscribers who have a tag, 50 per page.", + "List the forms and landing pages on the account.": "List the forms and landing pages on the account.", + "Subscribe an email address to a form, creating the subscriber if needed.": "Subscribe an email address to a form, creating the subscriber if needed.", + "List the subscribers who signed up through a form, 50 per page.": "List the subscribers who signed up through a form, 50 per page.", + "List the email sequences on the account.": "List the email sequences on the account.", + "List broadcasts (one-off emails), 50 per page.": "List broadcasts (one-off emails), 50 per page.", + "Get the content and settings of one broadcast.": "Get the content and settings of one broadcast.", + "Get delivery and engagement stats for one broadcast.": "Get delivery and engagement stats for one broadcast.", + "Create a broadcast email, as a draft or scheduled.": "Create a broadcast email, as a draft or scheduled.", + "Permanently delete a broadcast.": "Permanently delete a broadcast.", + "List the custom subscriber fields defined on the account.": "List the custom subscriber fields defined on the account.", + "Create a custom subscriber field.": "Create a custom subscriber field.", + "Rename a custom subscriber field.": "Rename a custom subscriber field.", + "Permanently delete a custom subscriber field and its values.": "Permanently delete a custom subscriber field and its values.", + "Register a webhook that Kit calls when an event happens.": "Register a webhook that Kit calls when an event happens.", + "Delete a webhook automation by its rule ID.": "Delete a webhook automation by its rule ID.", + "List the webhook automations registered on the Kit account.": "List the webhook automations registered on the Kit account.", + "Change fields on an existing broadcast. Fields left empty are not changed.": "Change fields on an existing broadcast. Fields left empty are not changed.", + "Enroll an email address in a sequence, creating the subscriber if needed.": "Enroll an email address in a sequence, creating the subscriber if needed.", + "List the subscribers enrolled in a sequence, 50 per page.": "List the subscribers enrolled in a sequence, 50 per page.", + "List the tags applied to one subscriber.": "List the tags applied to one subscriber.", + "List the purchases recorded in the account, 50 per page.": "List the purchases recorded in the account, 50 per page.", + "Get one purchase by purchase ID.": "Get one purchase by purchase ID.", + "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "Subscriber ID": "Subscriber ID", "Email": "Email", "Page": "Page", @@ -120,6 +168,34 @@ "Unit Price": "Unit Price", "Quantity": "Quantity", "Products": "Products", + "Created From": "Created From", + "Created To": "Created To", + "New Email Address": "New Email Address", + "Tag ID": "Tag ID", + "Additional Tag IDs": "Additional Tag IDs", + "Form ID": "Form ID", + "Tag IDs": "Tag IDs", + "Broadcast ID": "Broadcast ID", + "Internal Description": "Internal Description", + "From Email Address": "From Email Address", + "Email Template Name": "Email Template Name", + "Publish As Web Post": "Publish As Web Post", + "Thumbnail URL": "Thumbnail URL", + "Thumbnail Alt Text": "Thumbnail Alt Text", + "Label": "Label", + "Custom Field ID": "Custom Field ID", + "Sequence ID": "Sequence ID", + "Link URL": "Link URL", + "Rule ID": "Rule ID", + "Method": "Method", + "Headers": "Headers", + "Query Parameters": "Query Parameters", + "Body Type": "Body Type", + "Body": "Body", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", + "Follow redirects": "Follow redirects", "The subscriber ID": "The subscriber ID", "The email of the subscriber": "The email of the subscriber", "Page number. Each page of results will contain up to 50 subscribers.": "Page number. Each page of results will contain up to 50 subscribers.", @@ -173,6 +249,78 @@ "The unit price of the product": "The unit price of the product", "The quantity of the product": "The quantity of the product", "The products": "The products", + "Only return the subscriber with this email address.": "Only return the subscriber with this email address.", + "Only subscribers added on or after this date. Format yyyy-mm-dd.": "Only subscribers added on or after this date. Format yyyy-mm-dd.", + "Only subscribers added on or before this date. Format yyyy-mm-dd.": "Only subscribers added on or before this date. Format yyyy-mm-dd.", + "Only subscribers updated after this date. Format yyyy-mm-dd.": "Only subscribers updated after this date. Format yyyy-mm-dd.", + "Only subscribers updated before this date. Format yyyy-mm-dd.": "Only subscribers updated before this date. Format yyyy-mm-dd.", + "Page number, 50 subscribers per page. Defaults to 1.": "Page number, 50 subscribers per page. Defaults to 1.", + "Sort direction. Kit defaults to ascending.": "Sort direction. Kit defaults to ascending.", + "Set to Cancelled At to list cancelled subscribers, sorted by cancellation date.": "Set to Cancelled At to list cancelled subscribers, sorted by cancellation date.", + "The subscriber ID, from List Subscribers.": "The subscriber ID, from List Subscribers.", + "Change the subscriber email address.": "Change the subscriber email address.", + "The first name to store on the subscriber.": "The first name to store on the subscriber.", + "A JSON object of custom field key to value, e.g. {\"last_name\": \"Snow\"}. Keys must already exist; get them from List Custom Fields.": "A JSON object of custom field key to value, e.g. {\"last_name\": \"Snow\"}. Keys must already exist; get them from List Custom Fields.", + "The email address to unsubscribe.": "The email address to unsubscribe.", + "The tag name.": "The tag name.", + "The tag to apply, from List Tags or Create Tag.": "The tag to apply, from List Tags or Create Tag.", + "The subscriber email address.": "The subscriber email address.", + "More tag IDs to apply in the same call.": "More tag IDs to apply in the same call.", + "The tag to remove, from List Tags.": "The tag to remove, from List Tags.", + "The subscriber ID, from List Subscribers or Get Subscriber.": "The subscriber ID, from List Subscribers or Get Subscriber.", + "The tag ID, from List Tags.": "The tag ID, from List Tags.", + "Page number, 50 subscriptions per page. Defaults to 1.": "Page number, 50 subscriptions per page. Defaults to 1.", + "Sort by tagging date. Kit defaults to ascending (oldest first).": "Sort by tagging date. Kit defaults to ascending (oldest first).", + "Return only active or only cancelled subscriptions. Leave empty for the Kit default.": "Return only active or only cancelled subscriptions. Leave empty for the Kit default.", + "The form ID, from List Forms.": "The form ID, from List Forms.", + "The email address to subscribe.": "The email address to subscribe.", + "Optional tag IDs to apply as well, from List Tags.": "Optional tag IDs to apply as well, from List Tags.", + "Sort by subscription date. Kit defaults to ascending (oldest first).": "Sort by subscription date. Kit defaults to ascending (oldest first).", + "Page number, 50 broadcasts per page. Defaults to 1.": "Page number, 50 broadcasts per page. Defaults to 1.", + "Sort by creation date. Kit defaults to ascending (oldest first).": "Sort by creation date. Kit defaults to ascending (oldest first).", + "The broadcast ID, from List Broadcasts.": "The broadcast ID, from List Broadcasts.", + "The email subject line.": "The email subject line.", + "The email body. Plain text or simple HTML (p, h1, img, a).": "The email body. Plain text or simple HTML (p, h1, img, a).", + "An internal note, not shown to subscribers.": "An internal note, not shown to subscribers.", + "Sending address. Leave empty to use the account default.": "Sending address. Leave empty to use the account default.", + "Name of the email template. Leave empty to use the account default.": "Name of the email template. Leave empty to use the account default.", + "When to send, as an ISO 8601 date-time in the future. Leave empty to keep a draft. Setting it schedules a real send.": "When to send, as an ISO 8601 date-time in the future. Leave empty to keep a draft. Setting it schedules a real send.", + "Also publish the broadcast on the Creator Profile web page.": "Also publish the broadcast on the Creator Profile web page.", + "Publication time shown on the web post. Only used when publishing as a web post.": "Publication time shown on the web post. Only used when publishing as a web post.", + "Image URL for the web post thumbnail.": "Image URL for the web post thumbnail.", + "Alt text for the web post thumbnail.": "Alt text for the web post thumbnail.", + "The field label, e.g. \"Company\". Must be unique on the account.": "The field label, e.g. \"Company\". Must be unique on the account.", + "The field ID, from List Custom Fields.": "The field ID, from List Custom Fields.", + "The new label. Must be unique on the account.": "The new label. Must be unique on the account.", + "The public https:// URL Kit will POST the event payload to.": "The public https:// URL Kit will POST the event payload to.", + "The event that fires the webhook.": "The event that fires the webhook.", + "Required for the Form subscribed event. Get it from List Forms.": "Required for the Form subscribed event. Get it from List Forms.", + "Required for the Sequence subscribed and Sequence completed events. Get it from List Sequences.": "Required for the Sequence subscribed and Sequence completed events. Get it from List Sequences.", + "Required for the Tag added and Tag removed events. Get it from List Tags.": "Required for the Tag added and Tag removed events. Get it from List Tags.", + "Required for the Product purchased event.": "Required for the Product purchased event.", + "Required for the Link clicked event: the link URL to watch.": "Required for the Link clicked event: the link URL to watch.", + "The webhook rule ID, from Create Webhook or List Webhooks.": "The webhook rule ID, from Create Webhook or List Webhooks.", + "New email subject line.": "New email subject line.", + "New email body. Plain text or simple HTML (p, h1, img, a).": "New email body. Plain text or simple HTML (p, h1, img, a).", + "New internal note, not shown to subscribers.": "New internal note, not shown to subscribers.", + "New sending address. It must be a confirmed sender in Kit.": "New sending address. It must be a confirmed sender in Kit.", + "New email template name.": "New email template name.", + "Schedule a real send at this ISO 8601 date-time, which must be in the future. Leave empty to keep the current schedule.": "Schedule a real send at this ISO 8601 date-time, which must be in the future. Leave empty to keep the current schedule.", + "Change web post visibility. Leave empty to keep it as it is.": "Change web post visibility. Leave empty to keep it as it is.", + "New publication time shown on the web post.": "New publication time shown on the web post.", + "New image URL for the web post thumbnail.": "New image URL for the web post thumbnail.", + "New alt text for the web post thumbnail.": "New alt text for the web post thumbnail.", + "The sequence ID, from List Sequences.": "The sequence ID, from List Sequences.", + "The email address to enroll.": "The email address to enroll.", + "Sort by enrollment date. Kit defaults to ascending (oldest first).": "Sort by enrollment date. Kit defaults to ascending (oldest first).", + "Page number, 50 purchases per page. Defaults to 1.": "Page number, 50 purchases per page. Defaults to 1.", + "The Kit purchase ID, from List Purchases.": "The Kit purchase ID, from List Purchases.", + "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.", "Ascending": "Ascending", "Descending": "Descending", "Subscriber activated": "Subscriber activated", @@ -188,7 +336,7 @@ "Tag removed from subscriber": "Tag removed from subscriber", "Purchase created": "Purchase created", "Active": "Active", - "canceled": "canceled", + "Cancelled": "Cancelled", "paid": "paid", "pending": "pending", "failed": "failed", @@ -222,6 +370,17 @@ "AED": "AED", "SAR": "SAR", "ZAR": "ZAR", + "Ascending (oldest first)": "Ascending (oldest first)", + "Descending (newest first)": "Descending (newest first)", + "Cancelled At": "Cancelled At", + "Publish as web post": "Publish as web post", + "Do not publish as web post": "Do not publish as web post", + "GET": "GET", + "POST": "POST", + "PATCH": "PATCH", + "PUT": "PUT", + "DELETE": "DELETE", + "HEAD": "HEAD", "Trigger when a tag is added to a subscriber": "Trigger when a tag is added to a subscriber", "Trigger when a tag is removed from a subscriber": "Trigger when a tag is removed from a subscriber", "Trigger when a subscriber is activated. This happens when a subscriber confirms their subscription.": "Trigger when a subscriber is activated. This happens when a subscriber confirms their subscription.", diff --git a/packages/pieces/community/convertkit/src/index.ts b/packages/pieces/community/convertkit/src/index.ts index c00772f92e95..854ceade8a8d 100644 --- a/packages/pieces/community/convertkit/src/index.ts +++ b/packages/pieces/community/convertkit/src/index.ts @@ -1,4 +1,8 @@ -import { createPiece, PieceAuth } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { convertkitAuth } from './lib/auth'; +import { assertKitUrl } from './lib/common/custom-api-guard'; +import { CONVERTKIT_API_URL } from './lib/common/constants'; import { createField, deleteField, @@ -54,6 +58,39 @@ import { listPurchases, } from './lib/actions/purchases'; +import { kitUpdateBroadcast } from './lib/actions/ai/update-broadcast'; +import { kitAddSubscriberToSequence } from './lib/actions/ai/add-subscriber-to-sequence'; +import { kitListSequenceSubscriptions } from './lib/actions/ai/list-sequence-subscriptions'; +import { kitListSubscriberTags } from './lib/actions/ai/list-subscriber-tags'; +import { kitListPurchases } from './lib/actions/ai/list-purchases'; +import { kitGetPurchase } from './lib/actions/ai/get-purchase'; +import { kitGetAccount } from './lib/actions/ai/get-account'; +import { kitListSubscribers } from './lib/actions/ai/list-subscribers'; +import { kitGetSubscriber } from './lib/actions/ai/get-subscriber'; +import { kitUpdateSubscriber } from './lib/actions/ai/update-subscriber'; +import { kitUnsubscribeSubscriber } from './lib/actions/ai/unsubscribe-subscriber'; +import { kitListTags } from './lib/actions/ai/list-tags'; +import { kitCreateTag } from './lib/actions/ai/create-tag'; +import { kitTagSubscriber } from './lib/actions/ai/tag-subscriber'; +import { kitRemoveTagByEmail } from './lib/actions/ai/remove-tag-by-email'; +import { kitRemoveTagFromSubscriber } from './lib/actions/ai/remove-tag-from-subscriber'; +import { kitListTagSubscriptions } from './lib/actions/ai/list-tag-subscriptions'; +import { kitListForms } from './lib/actions/ai/list-forms'; +import { kitAddSubscriberToForm } from './lib/actions/ai/add-subscriber-to-form'; +import { kitListFormSubscriptions } from './lib/actions/ai/list-form-subscriptions'; +import { kitListSequences } from './lib/actions/ai/list-sequences'; +import { kitListBroadcasts } from './lib/actions/ai/list-broadcasts'; +import { kitGetBroadcast } from './lib/actions/ai/get-broadcast'; +import { kitGetBroadcastStats } from './lib/actions/ai/get-broadcast-stats'; +import { kitCreateBroadcast } from './lib/actions/ai/create-broadcast'; +import { kitDeleteBroadcast } from './lib/actions/ai/delete-broadcast'; +import { kitListCustomFields } from './lib/actions/ai/list-custom-fields'; +import { kitCreateCustomField } from './lib/actions/ai/create-custom-field'; +import { kitUpdateCustomField } from './lib/actions/ai/update-custom-field'; +import { kitDeleteCustomField } from './lib/actions/ai/delete-custom-field'; +import { kitCreateWebhook } from './lib/actions/ai/create-webhook'; +import { kitDeleteWebhook } from './lib/actions/ai/delete-webhook'; +import { kitListWebhooks } from './lib/actions/ai/list-webhooks'; import { PieceCategory } from '@activepieces/pieces-framework'; import { addTag, @@ -70,18 +107,12 @@ import { subscriberUnsubscribed, } from './lib/triggers'; -export const convertkitAuth = PieceAuth.SecretText({ - displayName: 'API Secret', - description: 'Enter your API Secret key', - required: true, -}); - export const convertkit = createPiece({ displayName: 'ConvertKit', description: 'Email marketing for creators', auth: convertkitAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/convertkit.png', categories: [PieceCategory.MARKETING], authors: ["Gunther-Schulz","kishanprmr","abuaboud"], @@ -121,6 +152,48 @@ export const convertkit = createPiece({ getPurchaseById, createSinglePurchase, createPurchases, + kitGetAccount, + kitListSubscribers, + kitGetSubscriber, + kitUpdateSubscriber, + kitUnsubscribeSubscriber, + kitListTags, + kitCreateTag, + kitTagSubscriber, + kitRemoveTagByEmail, + kitRemoveTagFromSubscriber, + kitListTagSubscriptions, + kitListForms, + kitAddSubscriberToForm, + kitListFormSubscriptions, + kitListSequences, + kitListBroadcasts, + kitGetBroadcast, + kitGetBroadcastStats, + kitCreateBroadcast, + kitDeleteBroadcast, + kitListCustomFields, + kitCreateCustomField, + kitUpdateCustomField, + kitDeleteCustomField, + kitCreateWebhook, + kitDeleteWebhook, + kitListWebhooks, + kitUpdateBroadcast, + kitAddSubscriberToSequence, + kitListSequenceSubscriptions, + kitListSubscriberTags, + kitListPurchases, + kitGetPurchase, + createCustomApiCallAction({ + baseUrl: () => CONVERTKIT_API_URL, + auth: convertkitAuth, + authLocation: 'queryParams', + authMapping: async (auth, propsValue) => { + assertKitUrl(propsValue); + return { api_secret: auth.secret_text }; + }, + }), ], triggers: [ addTag, @@ -137,3 +210,5 @@ export const convertkit = createPiece({ purchaseCreated, ], }); + +export { convertkitAuth }; diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-form.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-form.ts new file mode 100644 index 000000000000..23a25cdc16a8 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-form.ts @@ -0,0 +1,44 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitSubscriptionOutputSchema } from '../../output-schemas'; + +export const kitAddSubscriberToForm = createAction({ + auth: convertkitAuth, + name: 'kit_add_subscriber_to_form', + classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, + displayName: 'Add Subscriber To Form', + description: 'Subscribe an email address to a form, creating the subscriber if needed.', + audience: 'ai', + aiMetadata: { + description: + 'Subscribes an email to a form, creating the subscriber if missing, and can set first name, custom fields and extra tags. This is the main way to add a subscriber. Each call can send the form double opt-in or incentive email and may reactivate an address that unsubscribed, so only use it with consent; retries can resend that email. To act on a subscriber ID, get the email with Get Subscriber first.', + idempotent: false, + }, + props: { + form_id: kitProps.id('Form ID', 'The form ID, from List Forms.'), + email: kitProps.email('The email address to subscribe.'), + first_name: kitProps.firstName, + fields: kitProps.fields, + tag_ids: kitProps.tagIds('Tag IDs', 'Optional tag IDs to apply as well, from List Tags.'), + }, + async run(context) { + const { form_id, email, first_name, fields, tag_ids } = context.propsValue; + const response = await kitClient.request<{ subscription: Subscription }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: `/forms/${kitCommon.id({ value: form_id, label: 'Form ID' })}/subscribe`, + body: kitCommon.compact({ + email: kitCommon.email({ value: email, label: 'Email' }), + first_name, + fields: kitCommon.toFields(fields), + tags: kitCommon.toIdList(tag_ids), + }), + }); + return response.body.subscription; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-sequence.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-sequence.ts new file mode 100644 index 000000000000..6d9995c23c8f --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/add-subscriber-to-sequence.ts @@ -0,0 +1,44 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitSubscriptionOutputSchema } from '../../output-schemas'; + +export const kitAddSubscriberToSequence = createAction({ + auth: convertkitAuth, + name: 'kit_add_subscriber_to_sequence', + classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, + displayName: 'Add Subscriber To Sequence', + description: 'Enroll an email address in a sequence, creating the subscriber if needed.', + audience: 'ai', + aiMetadata: { + description: + 'Enrolls an email in a sequence, creating the subscriber if missing, and can set first name, custom fields and extra tags. This starts the sequence emails for that person and may reactivate an address that unsubscribed, so only use it with consent. Enrolling the same email again does not create a second enrollment. Get the sequence ID from List Sequences.', + idempotent: true, + }, + props: { + sequence_id: kitProps.id('Sequence ID', 'The sequence ID, from List Sequences.'), + email: kitProps.email('The email address to enroll.'), + first_name: kitProps.firstName, + fields: kitProps.fields, + tag_ids: kitProps.tagIds('Tag IDs', 'Optional tag IDs to apply as well, from List Tags.'), + }, + async run(context) { + const { sequence_id, email, first_name, fields, tag_ids } = context.propsValue; + const response = await kitClient.request<{ subscription: Subscription }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: `/sequences/${kitCommon.id({ value: sequence_id, label: 'Sequence ID' })}/subscribe`, + body: kitCommon.compact({ + email: kitCommon.email({ value: email, label: 'Email' }), + first_name, + fields: kitCommon.toFields(fields), + tags: kitCommon.toIdList(tag_ids), + }), + }); + return response.body.subscription; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/create-broadcast.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/create-broadcast.ts new file mode 100644 index 000000000000..cf697d85d067 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/create-broadcast.ts @@ -0,0 +1,99 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { Broadcast } from '../../common/types'; +import { kitBroadcastOutputSchema } from '../../output-schemas'; + +export const kitCreateBroadcast = createAction({ + auth: convertkitAuth, + name: 'kit_create_broadcast', + classification: 'WRITE', + outputSchema: kitBroadcastOutputSchema, + displayName: 'Create Broadcast', + description: 'Create a broadcast email, as a draft or scheduled.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a broadcast email. Without Send At it is saved as an unsent draft; setting Send At schedules a real send to the account subscribers at that time, with no segment targeting on this endpoint. Public only publishes it as a web post. Each call creates another broadcast.', + idempotent: false, + }, + props: { + subject: Property.ShortText({ + displayName: 'Subject', + description: 'The email subject line.', + required: false, + }), + content: Property.LongText({ + displayName: 'Content', + description: 'The email body. Plain text or simple HTML (p, h1, img, a).', + required: false, + }), + description: Property.ShortText({ + displayName: 'Internal Description', + description: 'An internal note, not shown to subscribers.', + required: false, + }), + email_address: Property.ShortText({ + displayName: 'From Email Address', + description: 'Sending address. Leave empty to use the account default.', + required: false, + }), + email_layout_template: Property.ShortText({ + displayName: 'Email Template Name', + description: 'Name of the email template. Leave empty to use the account default.', + required: false, + }), + send_at: Property.DateTime({ + displayName: 'Send At', + description: + 'When to send, as an ISO 8601 date-time in the future. Leave empty to keep a draft. Setting it schedules a real send.', + required: false, + }), + public: Property.Checkbox({ + displayName: 'Publish As Web Post', + description: 'Also publish the broadcast on the Creator Profile web page.', + required: false, + }), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'Publication time shown on the web post. Only used when publishing as a web post.', + required: false, + }), + thumbnail_url: Property.ShortText({ + displayName: 'Thumbnail URL', + description: 'Image URL for the web post thumbnail.', + required: false, + }), + thumbnail_alt: Property.ShortText({ + displayName: 'Thumbnail Alt Text', + description: 'Alt text for the web post thumbnail.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = kitCommon.compact({ + subject: props.subject, + content: props.content, + description: props.description, + email_address: + props.email_address === undefined || props.email_address === null || props.email_address.trim() === '' + ? undefined + : kitCommon.email({ value: props.email_address, label: 'From Email Address' }), + email_layout_template: props.email_layout_template, + send_at: kitCommon.toDateTime({ value: props.send_at, label: 'Send At', future: true }), + public: props.public === true ? true : undefined, + published_at: kitCommon.toDateTime({ value: props.published_at, label: 'Published At' }), + thumbnail_url: props.thumbnail_url, + thumbnail_alt: props.thumbnail_alt, + }); + const response = await kitClient.request<{ broadcast: Broadcast }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: '/broadcasts', + body, + }); + return response.body.broadcast; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/create-custom-field.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/create-custom-field.ts new file mode 100644 index 000000000000..0e36cee7de91 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/create-custom-field.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { CustomField } from '../../common/types'; +import { kitCustomFieldOutputSchema } from '../../output-schemas'; + +export const kitCreateCustomField = createAction({ + auth: convertkitAuth, + name: 'kit_create_custom_field', + classification: 'WRITE', + outputSchema: kitCustomFieldOutputSchema, + displayName: 'Create Custom Field', + description: 'Create a custom subscriber field.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one custom subscriber field from a label; Kit derives its key (lowercase, underscored) and returns it. Labels must be unique and the account allows at most 140 fields. Check List Custom Fields first, because a retry with an existing label fails.', + idempotent: false, + }, + props: { + label: Property.ShortText({ + displayName: 'Label', + description: 'The field label, e.g. "Company". Must be unique on the account.', + required: true, + }), + }, + async run(context) { + const label = context.propsValue.label.trim(); + if (!label) { + throw new Error('Label cannot be empty.'); + } + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: '/custom_fields', + body: { label }, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/create-tag.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/create-tag.ts new file mode 100644 index 000000000000..b1c95611c773 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/create-tag.ts @@ -0,0 +1,52 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { Tag } from '../../common/types'; +import { kitCreateTagOutputSchema } from '../../output-schemas'; + +export const kitCreateTag = createAction({ + auth: convertkitAuth, + name: 'kit_create_tag', + classification: 'WRITE', + outputSchema: kitCreateTagOutputSchema, + displayName: 'Create Tag', + description: 'Create a tag, or return the existing tag with the same name.', + audience: 'ai', + aiMetadata: { + description: + 'Gets or creates a tag by name: if a tag with the same name exists (case-insensitive) it is returned with created false, otherwise a new tag is created with created true. Safe to retry.', + idempotent: true, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'The tag name.', + required: true, + }), + }, + async run(context) { + const name = context.propsValue.name.trim(); + if (!name) { + throw new Error('Tag name cannot be empty.'); + } + const existing = await kitClient.request<{ tags: Tag[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/tags', + }); + const match = (existing.body.tags ?? []).find( + (tag) => tag.name.trim().toLowerCase() === name.toLowerCase() + ); + if (match) { + return { ...match, created: false }; + } + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: '/tags', + body: { tag: { name } }, + }); + return { ...response.body, created: true }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/create-webhook.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/create-webhook.ts new file mode 100644 index 000000000000..880981ebb3a9 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/create-webhook.ts @@ -0,0 +1,107 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { createWebhookParameterOptions } from '../../common/webhooks'; +import { Webhook } from '../../common/types'; +import { kitWebhookOutputSchema } from '../../output-schemas'; + +const numericParameters = ['form_id', 'sequence_id', 'tag_id', 'product_id']; + +export const kitCreateWebhook = createAction({ + auth: convertkitAuth, + name: 'kit_create_webhook', + classification: 'WRITE', + outputSchema: kitWebhookOutputSchema, + displayName: 'Create Webhook', + description: 'Register a webhook that Kit calls when an event happens.', + audience: 'ai', + aiMetadata: { + description: + 'Registers a webhook rule that POSTs to a target URL when the chosen event fires. Form, sequence, tag, product and link-click events need their matching parameter (Form ID, Sequence ID, Tag ID, Product ID or Link URL). Each call registers another rule, so check List Webhooks first and keep the returned rule ID for Delete Webhook.', + idempotent: false, + }, + props: { + target_url: Property.ShortText({ + displayName: 'Target URL', + description: 'The public https:// URL Kit will POST the event payload to.', + required: true, + }), + event: Property.StaticDropdown({ + displayName: 'Event', + description: 'The event that fires the webhook.', + required: true, + options: { + options: createWebhookParameterOptions.map((option) => ({ + label: option.label, + value: option.value, + })), + }, + }), + form_id: Property.Number({ + displayName: 'Form ID', + description: 'Required for the Form subscribed event. Get it from List Forms.', + required: false, + }), + sequence_id: Property.Number({ + displayName: 'Sequence ID', + description: + 'Required for the Sequence subscribed and Sequence completed events. Get it from List Sequences.', + required: false, + }), + tag_id: Property.Number({ + displayName: 'Tag ID', + description: + 'Required for the Tag added and Tag removed events. Get it from List Tags.', + required: false, + }), + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Required for the Product purchased event.', + required: false, + }), + initiator_value: Property.ShortText({ + displayName: 'Link URL', + description: 'Required for the Link clicked event: the link URL to watch.', + required: false, + }), + }, + async run(context) { + const { event } = context.propsValue; + const target_url = context.propsValue.target_url.trim(); + if (!/^https:\/\/[^\s/]+\.[^\s]+$/i.test(target_url)) { + throw new Error(`Target URL must be a public https:// URL, got "${context.propsValue.target_url}".`); + } + const option = createWebhookParameterOptions.find((item) => item.value === event); + if (!option) { + throw new Error(`Unknown event "${String(event)}".`); + } + const eventBody: Record = { name: event }; + const parameter = option.required_parameter; + if (parameter) { + const propValues: Record = { ...context.propsValue }; + const raw = propValues[parameter]; + if (raw === undefined || raw === null || raw === '') { + throw new Error( + `The "${option.label}" event requires ${option.param_label ?? parameter}.` + ); + } + if (numericParameters.includes(parameter)) { + const id = Number(raw); + if (!Number.isInteger(id)) { + throw new Error(`${option.param_label ?? parameter} must be a whole number.`); + } + eventBody[parameter] = id; + } else { + eventBody[parameter] = String(raw); + } + } + const response = await kitClient.request<{ rule: Webhook }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: '/automations/hooks', + body: { target_url, event: eventBody }, + }); + return response.body.rule; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/delete-broadcast.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-broadcast.ts new file mode 100644 index 000000000000..e9d36734be44 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-broadcast.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { kitDeleteBroadcastOutputSchema } from '../../output-schemas'; + +export const kitDeleteBroadcast = createAction({ + auth: convertkitAuth, + name: 'kit_delete_broadcast', + classification: 'DESTRUCTIVE', + outputSchema: kitDeleteBroadcastOutputSchema, + displayName: 'Delete Broadcast', + description: 'Permanently delete a broadcast.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a draft or scheduled broadcast by ID; a scheduled one will not be sent. It cannot be undone, and a repeat call fails once the broadcast is gone. Confirm the ID with Get Broadcast first.', + idempotent: false, + }, + props: { + broadcast_id: kitProps.id('Broadcast ID', 'The broadcast ID, from List Broadcasts.'), + }, + async run(context) { + const broadcastId = kitCommon.id({ value: context.propsValue.broadcast_id, label: 'Broadcast ID' }); + await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.DELETE, + path: `/broadcasts/${broadcastId}`, + }); + return { success: true, broadcast_id: broadcastId }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/delete-custom-field.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-custom-field.ts new file mode 100644 index 000000000000..805c27738681 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-custom-field.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { kitDeleteCustomFieldOutputSchema } from '../../output-schemas'; + +export const kitDeleteCustomField = createAction({ + auth: convertkitAuth, + name: 'kit_delete_custom_field', + classification: 'DESTRUCTIVE', + outputSchema: kitDeleteCustomFieldOutputSchema, + displayName: 'Delete Custom Field', + description: 'Permanently delete a custom subscriber field and its values.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a custom field by ID, erasing its stored value on every subscriber. It cannot be undone, and a repeat call fails once the field is gone.', + idempotent: false, + }, + props: { + custom_field_id: kitProps.id('Custom Field ID', 'The field ID, from List Custom Fields.'), + }, + async run(context) { + const fieldId = kitCommon.id({ value: context.propsValue.custom_field_id, label: 'Custom Field ID' }); + await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.DELETE, + path: `/custom_fields/${fieldId}`, + }); + return { success: true, custom_field_id: fieldId }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/delete-webhook.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-webhook.ts new file mode 100644 index 000000000000..f52eff66e99f --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/delete-webhook.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { kitDeleteWebhookOutputSchema } from '../../output-schemas'; + +export const kitDeleteWebhook = createAction({ + auth: convertkitAuth, + name: 'kit_delete_webhook', + classification: 'DESTRUCTIVE', + outputSchema: kitDeleteWebhookOutputSchema, + displayName: 'Delete Webhook', + description: 'Delete a webhook automation by its rule ID.', + audience: 'ai', + aiMetadata: { + description: + 'Deletes a webhook rule by its rule ID, from Create Webhook or List Webhooks. Kit stops calling the target URL immediately. A repeat call fails once the rule is gone.', + idempotent: false, + }, + props: { + rule_id: kitProps.id('Rule ID', 'The webhook rule ID, from Create Webhook or List Webhooks.'), + }, + async run(context) { + const ruleId = kitCommon.id({ value: context.propsValue.rule_id, label: 'Rule ID' }); + const response = await kitClient.request<{ success?: boolean }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.DELETE, + path: `/automations/hooks/${ruleId}`, + }); + return { success: response.body?.success ?? true, rule_id: ruleId }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/get-account.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/get-account.ts new file mode 100644 index 000000000000..4f7fa6064184 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/get-account.ts @@ -0,0 +1,29 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { kitGetAccountOutputSchema } from '../../output-schemas'; + +export const kitGetAccount = createAction({ + auth: convertkitAuth, + name: 'kit_get_account', + classification: 'READ', + outputSchema: kitGetAccountOutputSchema, + displayName: 'Get Account', + description: 'Get the name and primary email address of the connected Kit account.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the connected Kit account name and primary email address. Use it to confirm which account the connection points at before making changes. Returns no account ID or timezone.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/account', + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast-stats.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast-stats.ts new file mode 100644 index 000000000000..a762a77f87dd --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast-stats.ts @@ -0,0 +1,45 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { kitGetBroadcastStatsOutputSchema } from '../../output-schemas'; + +type BroadcastStats = { + id?: number; + stats?: Record; +}; + +export const kitGetBroadcastStats = createAction({ + auth: convertkitAuth, + name: 'kit_get_broadcast_stats', + classification: 'READ', + outputSchema: kitGetBroadcastStatsOutputSchema, + displayName: 'Get Broadcast Stats', + description: 'Get delivery and engagement stats for one broadcast.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the stats of one broadcast by ID: recipients, open rate, click rate, unsubscribes, total clicks, status and send progress. Use Get Broadcast for its content instead. A draft that was never sent reports zero counts.', + idempotent: true, + }, + props: { + broadcast_id: kitProps.id('Broadcast ID', 'The broadcast ID, from List Broadcasts.'), + }, + async run(context) { + const broadcastId = kitCommon.id({ value: context.propsValue.broadcast_id, label: 'Broadcast ID' }); + const response = await kitClient.request<{ + broadcast: BroadcastStats | BroadcastStats[]; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/broadcasts/${broadcastId}/stats`, + }); + const raw = response.body.broadcast; + const broadcast = Array.isArray(raw) ? raw[0] : raw; + return { + broadcast_id: broadcast?.id ?? Number(broadcastId), + ...(broadcast?.stats ?? {}), + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast.ts new file mode 100644 index 000000000000..fd79f9c16e11 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/get-broadcast.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Broadcast } from '../../common/types'; +import { kitBroadcastOutputSchema } from '../../output-schemas'; + +export const kitGetBroadcast = createAction({ + auth: convertkitAuth, + name: 'kit_get_broadcast', + classification: 'READ', + outputSchema: kitBroadcastOutputSchema, + displayName: 'Get Broadcast', + description: 'Get the content and settings of one broadcast.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one broadcast by ID: subject, content, sender, schedule and public-post settings. Use Get Broadcast Stats for opens and clicks instead. Get the ID from List Broadcasts.', + idempotent: true, + }, + props: { + broadcast_id: kitProps.id('Broadcast ID', 'The broadcast ID, from List Broadcasts.'), + }, + async run(context) { + const response = await kitClient.request<{ broadcast: Broadcast }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/broadcasts/${kitCommon.id({ value: context.propsValue.broadcast_id, label: 'Broadcast ID' })}`, + }); + return response.body.broadcast; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/get-purchase.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/get-purchase.ts new file mode 100644 index 000000000000..2ec90567b04b --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/get-purchase.ts @@ -0,0 +1,36 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Purchase } from '../../common/types'; +import { kitPurchaseOutputSchema } from '../../output-schemas'; + +export const kitGetPurchase = createAction({ + auth: convertkitAuth, + name: 'kit_get_purchase', + classification: 'READ', + outputSchema: kitPurchaseOutputSchema, + displayName: 'Get Purchase', + description: 'Get one purchase by purchase ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one purchase record by its Kit purchase ID: transaction ID, buyer email, status, currency, totals and product line items. Find the purchase ID with List Purchases.', + idempotent: true, + }, + props: { + purchase_id: kitProps.id( + 'Purchase ID', + 'The Kit purchase ID, from List Purchases.' + ), + }, + async run(context) { + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/purchases/${kitCommon.id({ value: context.propsValue.purchase_id, label: 'Purchase ID' })}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/get-subscriber.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/get-subscriber.ts new file mode 100644 index 000000000000..8319ec462cd7 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/get-subscriber.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscriber } from '../../common/types'; +import { kitSubscriberOutputSchema } from '../../output-schemas'; + +export const kitGetSubscriber = createAction({ + auth: convertkitAuth, + name: 'kit_get_subscriber', + classification: 'READ', + outputSchema: kitSubscriberOutputSchema, + displayName: 'Get Subscriber', + description: 'Get one subscriber by subscriber ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one subscriber by ID: email, first name, state and custom field values. To look someone up by email, use List Subscribers with the Email Address filter instead.', + idempotent: true, + }, + props: { + subscriber_id: kitProps.id('Subscriber ID', 'The subscriber ID, from List Subscribers.'), + }, + async run(context) { + const response = await kitClient.request<{ subscriber: Subscriber }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/subscribers/${kitCommon.id({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' })}`, + }); + return response.body.subscriber; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-broadcasts.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-broadcasts.ts new file mode 100644 index 000000000000..55bf716f82ae --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-broadcasts.ts @@ -0,0 +1,37 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Broadcast } from '../../common/types'; +import { kitListBroadcastsOutputSchema } from '../../output-schemas'; + +export const kitListBroadcasts = createAction({ + auth: convertkitAuth, + name: 'kit_list_broadcasts', + classification: 'SEARCH', + outputSchema: kitListBroadcastsOutputSchema, + displayName: 'List Broadcasts', + description: 'List broadcasts (one-off emails), 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists broadcasts with their ID, subject and creation time, 50 per page. Use it to find a broadcast ID for Get Broadcast, Get Broadcast Stats or Delete Broadcast. Increase Page until fewer than 50 are returned to see older ones.', + idempotent: true, + }, + props: { + page: kitProps.page('Page number, 50 broadcasts per page. Defaults to 1.'), + sort_order: kitProps.sortOrder('Sort by creation date. Kit defaults to ascending (oldest first).'), + }, + async run(context) { + const page = kitCommon.page(context.propsValue.page); + const response = await kitClient.request<{ broadcasts: Broadcast[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/broadcasts', + query: { page, sort_order: context.propsValue.sort_order }, + }); + const broadcasts = response.body.broadcasts ?? []; + return { broadcasts, count: broadcasts.length, page }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-custom-fields.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-custom-fields.ts new file mode 100644 index 000000000000..a42ffb751178 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-custom-fields.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { CustomField } from '../../common/types'; +import { kitListCustomFieldsOutputSchema } from '../../output-schemas'; + +export const kitListCustomFields = createAction({ + auth: convertkitAuth, + name: 'kit_list_custom_fields', + classification: 'SEARCH', + outputSchema: kitListCustomFieldsOutputSchema, + displayName: 'List Custom Fields', + description: 'List the custom subscriber fields defined on the account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists every custom field with its ID, label and key. Use the key when setting Custom Fields on subscribers, and the ID for Update or Delete Custom Field.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request<{ custom_fields: CustomField[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/custom_fields', + }); + const customFields = response.body.custom_fields ?? []; + return { custom_fields: customFields, count: customFields.length }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-form-subscriptions.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-form-subscriptions.ts new file mode 100644 index 000000000000..d3027dc2b125 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-form-subscriptions.ts @@ -0,0 +1,48 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitListTagSubscriptionsOutputSchema } from '../../output-schemas'; + +export const kitListFormSubscriptions = createAction({ + auth: convertkitAuth, + name: 'kit_list_form_subscriptions', + classification: 'SEARCH', + outputSchema: kitListTagSubscriptionsOutputSchema, + displayName: 'List Form Subscriptions', + description: 'List the subscribers who signed up through a form, 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists subscriptions to one form, each with the subscriber record, 50 per page, with total counts for paging. Filter by active or cancelled state. Get the form ID from List Forms.', + idempotent: true, + }, + props: { + form_id: kitProps.id('Form ID', 'The form ID, from List Forms.'), + page: kitProps.page('Page number, 50 subscriptions per page. Defaults to 1.'), + sort_order: kitProps.sortOrder('Sort by subscription date. Kit defaults to ascending (oldest first).'), + subscriber_state: kitProps.subscriberState, + }, + async run(context) { + const { form_id, page, sort_order, subscriber_state } = context.propsValue; + const response = await kitClient.request<{ + subscriptions: Subscription[]; + page: number; + total_pages: number; + total_subscriptions: number; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/forms/${kitCommon.id({ value: form_id, label: 'Form ID' })}/subscriptions`, + query: { page: kitCommon.page(page), sort_order, subscriber_state }, + }); + return { + subscriptions: response.body.subscriptions ?? [], + page: response.body.page, + total_pages: response.body.total_pages, + total_subscriptions: response.body.total_subscriptions, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-forms.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-forms.ts new file mode 100644 index 000000000000..dee40f69f852 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-forms.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { Form } from '../../common/types'; +import { kitListFormsOutputSchema } from '../../output-schemas'; + +export const kitListForms = createAction({ + auth: convertkitAuth, + name: 'kit_list_forms', + classification: 'SEARCH', + outputSchema: kitListFormsOutputSchema, + displayName: 'List Forms', + description: 'List the forms and landing pages on the account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists all forms and landing pages with their ID, name, type and embed URL. Use it to find a form ID for Add Subscriber To Form or List Form Subscriptions.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request<{ forms: Form[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/forms', + }); + const forms = response.body.forms ?? []; + return { forms, count: forms.length }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-purchases.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-purchases.ts new file mode 100644 index 000000000000..31ee71c1f356 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-purchases.ts @@ -0,0 +1,44 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Purchase } from '../../common/types'; +import { kitListPurchasesOutputSchema } from '../../output-schemas'; + +export const kitListPurchases = createAction({ + auth: convertkitAuth, + name: 'kit_list_purchases', + classification: 'SEARCH', + outputSchema: kitListPurchasesOutputSchema, + displayName: 'List Purchases', + description: 'List the purchases recorded in the account, 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists purchase records in the account, newest pages last, 50 per page, with total counts for paging. Each purchase has its transaction ID, buyer email, currency, totals and products. There is no filter by buyer on this endpoint.', + idempotent: true, + }, + props: { + page: kitProps.page('Page number, 50 purchases per page. Defaults to 1.'), + }, + async run(context) { + const response = await kitClient.request<{ + purchases: Purchase[]; + page: number; + total_pages: number; + total_purchases: number; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/purchases', + query: { page: kitCommon.page(context.propsValue.page) }, + }); + return { + purchases: response.body.purchases ?? [], + page: response.body.page, + total_pages: response.body.total_pages, + total_purchases: response.body.total_purchases, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequence-subscriptions.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequence-subscriptions.ts new file mode 100644 index 000000000000..2424a91a1ba4 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequence-subscriptions.ts @@ -0,0 +1,48 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitListTagSubscriptionsOutputSchema } from '../../output-schemas'; + +export const kitListSequenceSubscriptions = createAction({ + auth: convertkitAuth, + name: 'kit_list_sequence_subscriptions', + classification: 'SEARCH', + outputSchema: kitListTagSubscriptionsOutputSchema, + displayName: 'List Sequence Subscriptions', + description: 'List the subscribers enrolled in a sequence, 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the subscribers enrolled in one sequence, each with the subscriber record, 50 per page, with total counts for paging. Filter by active or cancelled state. Get the sequence ID from List Sequences.', + idempotent: true, + }, + props: { + sequence_id: kitProps.id('Sequence ID', 'The sequence ID, from List Sequences.'), + page: kitProps.page('Page number, 50 subscriptions per page. Defaults to 1.'), + sort_order: kitProps.sortOrder('Sort by enrollment date. Kit defaults to ascending (oldest first).'), + subscriber_state: kitProps.subscriberState, + }, + async run(context) { + const { sequence_id, page, sort_order, subscriber_state } = context.propsValue; + const response = await kitClient.request<{ + subscriptions: Subscription[]; + page: number; + total_pages: number; + total_subscriptions: number; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/sequences/${kitCommon.id({ value: sequence_id, label: 'Sequence ID' })}/subscriptions`, + query: { page: kitCommon.page(page), sort_order, subscriber_state }, + }); + return { + subscriptions: response.body.subscriptions ?? [], + page: response.body.page, + total_pages: response.body.total_pages, + total_subscriptions: response.body.total_subscriptions, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequences.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequences.ts new file mode 100644 index 000000000000..cc8efbadbe00 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-sequences.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { Sequence } from '../../common/types'; +import { kitListSequencesOutputSchema } from '../../output-schemas'; + +export const kitListSequences = createAction({ + auth: convertkitAuth, + name: 'kit_list_sequences', + classification: 'SEARCH', + outputSchema: kitListSequencesOutputSchema, + displayName: 'List Sequences', + description: 'List the email sequences on the account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists all sequences (automated email courses) with their ID and name. Use it to find a sequence ID for a sequence webhook or enrolment.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request<{ courses: Sequence[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/sequences', + }); + const sequences = response.body.courses ?? []; + return { sequences, count: sequences.length }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscriber-tags.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscriber-tags.ts new file mode 100644 index 000000000000..40542ef1980b --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscriber-tags.ts @@ -0,0 +1,34 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Tag } from '../../common/types'; +import { kitListTagsOutputSchema } from '../../output-schemas'; + +export const kitListSubscriberTags = createAction({ + auth: convertkitAuth, + name: 'kit_list_subscriber_tags', + classification: 'SEARCH', + outputSchema: kitListTagsOutputSchema, + displayName: 'List Subscriber Tags', + description: 'List the tags applied to one subscriber.', + audience: 'ai', + aiMetadata: { + description: + 'Lists every tag currently applied to one subscriber, by subscriber ID. When only an email is known, find the ID with List Subscribers and its Email Address filter first.', + idempotent: true, + }, + props: { + subscriber_id: kitProps.id('Subscriber ID', 'The subscriber ID, from List Subscribers.'), + }, + async run(context) { + const response = await kitClient.request<{ tags: Tag[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/subscribers/${kitCommon.id({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' })}/tags`, + }); + const tags = response.body.tags ?? []; + return { tags, count: tags.length }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscribers.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscribers.ts new file mode 100644 index 000000000000..909b88433375 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-subscribers.ts @@ -0,0 +1,101 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscriber } from '../../common/types'; +import { kitListSubscribersOutputSchema } from '../../output-schemas'; + +const dateProp = ({ + displayName, + description, +}: { + displayName: string; + description: string; +}) => + Property.ShortText({ + displayName, + description: `${description} Format yyyy-mm-dd.`, + required: false, + }); + +export const kitListSubscribers = createAction({ + auth: convertkitAuth, + name: 'kit_list_subscribers', + classification: 'SEARCH', + outputSchema: kitListSubscribersOutputSchema, + displayName: 'List Subscribers', + description: 'List or search subscribers, 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists subscribers 50 per page with total counts, optionally filtered by exact email address or created/updated date range. This is also the way to find a subscriber ID from an email. Kit returns active subscribers by default; set Sort Field to cancelled_at to list cancelled ones.', + idempotent: true, + }, + props: { + email_address: Property.ShortText({ + displayName: 'Email Address', + description: 'Only return the subscriber with this email address.', + required: false, + }), + from: dateProp({ + displayName: 'Created From', + description: 'Only subscribers added on or after this date.', + }), + to: dateProp({ + displayName: 'Created To', + description: 'Only subscribers added on or before this date.', + }), + updated_from: dateProp({ + displayName: 'Updated From', + description: 'Only subscribers updated after this date.', + }), + updated_to: dateProp({ + displayName: 'Updated To', + description: 'Only subscribers updated before this date.', + }), + page: kitProps.page('Page number, 50 subscribers per page. Defaults to 1.'), + sort_order: kitProps.sortOrder('Sort direction. Kit defaults to ascending.'), + sort_field: Property.StaticDropdown({ + displayName: 'Sort Field', + description: 'Set to Cancelled At to list cancelled subscribers, sorted by cancellation date.', + required: false, + options: { + options: [{ label: 'Cancelled At', value: 'cancelled_at' }], + }, + }), + }, + async run(context) { + const props = context.propsValue; + const page = kitCommon.page(props.page); + const response = await kitClient.request<{ + subscribers: Subscriber[]; + page: number; + total_pages: number; + total_subscribers: number; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/subscribers', + query: { + page, + email_address: + props.email_address === undefined || props.email_address === null || props.email_address.trim() === '' + ? undefined + : kitCommon.email({ value: props.email_address, label: 'Email Address' }), + from: kitCommon.toDate(props.from), + to: kitCommon.toDate(props.to), + updated_from: kitCommon.toDate(props.updated_from), + updated_to: kitCommon.toDate(props.updated_to), + sort_order: props.sort_order, + sort_field: props.sort_field, + }, + }); + return { + subscribers: response.body.subscribers ?? [], + page: response.body.page ?? page, + total_pages: response.body.total_pages, + total_subscribers: response.body.total_subscribers, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-tag-subscriptions.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-tag-subscriptions.ts new file mode 100644 index 000000000000..ddc8f54829ec --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-tag-subscriptions.ts @@ -0,0 +1,48 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitListTagSubscriptionsOutputSchema } from '../../output-schemas'; + +export const kitListTagSubscriptions = createAction({ + auth: convertkitAuth, + name: 'kit_list_tag_subscriptions', + classification: 'SEARCH', + outputSchema: kitListTagSubscriptionsOutputSchema, + displayName: 'List Tag Subscriptions', + description: 'List the subscribers who have a tag, 50 per page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the subscribers who have one tag, each with the subscriber record, 50 per page, with total counts for paging. Filter by active or cancelled state. Get the tag ID from List Tags.', + idempotent: true, + }, + props: { + tag_id: kitProps.id('Tag ID', 'The tag ID, from List Tags.'), + page: kitProps.page('Page number, 50 subscriptions per page. Defaults to 1.'), + sort_order: kitProps.sortOrder('Sort by tagging date. Kit defaults to ascending (oldest first).'), + subscriber_state: kitProps.subscriberState, + }, + async run(context) { + const { tag_id, page, sort_order, subscriber_state } = context.propsValue; + const response = await kitClient.request<{ + subscriptions: Subscription[]; + page: number; + total_pages: number; + total_subscriptions: number; + }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: `/tags/${kitCommon.id({ value: tag_id, label: 'Tag ID' })}/subscriptions`, + query: { page: kitCommon.page(page), sort_order, subscriber_state }, + }); + return { + subscriptions: response.body.subscriptions ?? [], + page: response.body.page, + total_pages: response.body.total_pages, + total_subscriptions: response.body.total_subscriptions, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-tags.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-tags.ts new file mode 100644 index 000000000000..ad75a3b1c256 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-tags.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { Tag } from '../../common/types'; +import { kitListTagsOutputSchema } from '../../output-schemas'; + +export const kitListTags = createAction({ + auth: convertkitAuth, + name: 'kit_list_tags', + classification: 'SEARCH', + outputSchema: kitListTagsOutputSchema, + displayName: 'List Tags', + description: 'List the tags on the account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists every tag with its ID and name. Use it to resolve a tag name to the ID that Tag Subscriber, Remove Tag and List Tag Subscriptions need.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request<{ tags: Tag[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/tags', + }); + const tags = response.body.tags ?? []; + return { tags, count: tags.length }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/list-webhooks.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/list-webhooks.ts new file mode 100644 index 000000000000..81c714a6491a --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/list-webhooks.ts @@ -0,0 +1,49 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient } from '../../common/client'; +import { kitListWebhooksOutputSchema } from '../../output-schemas'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const rulesOf = (body: unknown): unknown[] => { + if (Array.isArray(body)) { + return body; + } + if (isRecord(body) && Array.isArray(body['rules'])) { + return body['rules']; + } + return []; +}; + +export const kitListWebhooks = createAction({ + auth: convertkitAuth, + name: 'kit_list_webhooks', + classification: 'SEARCH', + outputSchema: kitListWebhooksOutputSchema, + displayName: 'List Webhooks', + description: 'List the webhook automations registered on the Kit account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the webhook rules registered on the account, each with its rule ID, event and target URL. Use it to find a rule ID before Delete Webhook or to avoid registering a duplicate with Create Webhook.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/automations/hooks', + }); + const body = response.body; + const rules = rulesOf(body); + return { + rules: rules.map((item) => + isRecord(item) && 'rule' in item ? item['rule'] : item + ), + count: rules.length, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-by-email.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-by-email.ts new file mode 100644 index 000000000000..384966cc1232 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-by-email.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Tag } from '../../common/types'; +import { kitRemoveTagByEmailOutputSchema } from '../../output-schemas'; + +export const kitRemoveTagByEmail = createAction({ + auth: convertkitAuth, + name: 'kit_remove_tag_by_email', + classification: 'WRITE', + outputSchema: kitRemoveTagByEmailOutputSchema, + displayName: 'Remove Tag By Email', + description: 'Remove a tag from a subscriber identified by email.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one tag from the subscriber with the given email; the subscriber stays subscribed. Use Remove Tag From Subscriber when you have the subscriber ID instead. Safe to retry: removing a tag the subscriber no longer has still succeeds.', + idempotent: true, + }, + props: { + tag_id: kitProps.id('Tag ID', 'The tag to remove, from List Tags.'), + email: kitProps.email('The subscriber email address.'), + }, + async run(context) { + const email = kitCommon.email({ value: context.propsValue.email, label: 'Email' }); + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: `/tags/${kitCommon.id({ value: context.propsValue.tag_id, label: 'Tag ID' })}/unsubscribe`, + body: { email }, + }); + return { + removed: true, + email, + tag_id: response.body?.id, + tag_name: response.body?.name, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-from-subscriber.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-from-subscriber.ts new file mode 100644 index 000000000000..e2469303b086 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/remove-tag-from-subscriber.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Tag } from '../../common/types'; +import { kitRemoveTagFromSubscriberOutputSchema } from '../../output-schemas'; + +export const kitRemoveTagFromSubscriber = createAction({ + auth: convertkitAuth, + name: 'kit_remove_tag_from_subscriber', + classification: 'WRITE', + outputSchema: kitRemoveTagFromSubscriberOutputSchema, + displayName: 'Remove Tag From Subscriber', + description: 'Remove a tag from a subscriber identified by subscriber ID.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one tag from a subscriber by subscriber ID; the subscriber stays subscribed. Use Remove Tag By Email when you only have the address. Safe to retry: removing a tag the subscriber no longer has still succeeds.', + idempotent: true, + }, + props: { + subscriber_id: kitProps.id('Subscriber ID', 'The subscriber ID, from List Subscribers or Get Subscriber.'), + tag_id: kitProps.id('Tag ID', 'The tag to remove, from List Tags.'), + }, + async run(context) { + const subscriberId = kitCommon.id({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' }); + const tagId = kitCommon.id({ value: context.propsValue.tag_id, label: 'Tag ID' }); + const response = await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.DELETE, + path: `/subscribers/${subscriberId}/tags/${tagId}`, + }); + return { + removed: true, + subscriber_id: subscriberId, + tag_id: response.body?.id ?? Number(tagId), + tag_name: response.body?.name, + }; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/tag-subscriber.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/tag-subscriber.ts new file mode 100644 index 000000000000..608cbe977c75 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/tag-subscriber.ts @@ -0,0 +1,44 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscription } from '../../common/types'; +import { kitSubscriptionOutputSchema } from '../../output-schemas'; + +export const kitTagSubscriber = createAction({ + auth: convertkitAuth, + name: 'kit_tag_subscriber', + classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, + displayName: 'Tag Subscriber', + description: 'Apply a tag to a subscriber by email, creating the subscriber if needed.', + audience: 'ai', + aiMetadata: { + description: + 'Applies a tag (plus optional extra tags) to a subscriber by email, creating the subscriber if missing and optionally setting first name and custom fields. Re-applying the same tag is a no-op. Tagging can start automations tied to that tag and may reactivate an address that unsubscribed. To act on a subscriber ID, get the email with Get Subscriber first.', + idempotent: true, + }, + props: { + tag_id: kitProps.id('Tag ID', 'The tag to apply, from List Tags or Create Tag.'), + email: kitProps.email('The subscriber email address.'), + additional_tag_ids: kitProps.tagIds('Additional Tag IDs', 'More tag IDs to apply in the same call.'), + first_name: kitProps.firstName, + fields: kitProps.fields, + }, + async run(context) { + const { tag_id, email, additional_tag_ids, first_name, fields } = context.propsValue; + const response = await kitClient.request<{ subscription: Subscription }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.POST, + path: `/tags/${kitCommon.id({ value: tag_id, label: 'Tag ID' })}/subscribe`, + body: kitCommon.compact({ + email: kitCommon.email({ value: email, label: 'Email' }), + first_name, + fields: kitCommon.toFields(fields), + tags: kitCommon.toIdList(additional_tag_ids), + }), + }); + return response.body.subscription; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/unsubscribe-subscriber.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/unsubscribe-subscriber.ts new file mode 100644 index 000000000000..e4b1b8768a32 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/unsubscribe-subscriber.ts @@ -0,0 +1,34 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscriber } from '../../common/types'; +import { kitSubscriberOutputSchema } from '../../output-schemas'; + +export const kitUnsubscribeSubscriber = createAction({ + auth: convertkitAuth, + name: 'kit_unsubscribe_subscriber', + classification: 'DESTRUCTIVE', + outputSchema: kitSubscriberOutputSchema, + displayName: 'Unsubscribe Subscriber', + description: 'Unsubscribe an email address from all forms and sequences.', + audience: 'ai', + aiMetadata: { + description: + 'Unsubscribes an email address from every form, sequence and future email on the account; Kit has no delete-subscriber endpoint, so this is the removal action. Only the subscriber can reliably opt back in. To act on a subscriber ID, get the email with Get Subscriber first. Safe to retry: an already unsubscribed address returns the same cancelled record.', + idempotent: true, + }, + props: { + email: kitProps.email('The email address to unsubscribe.'), + }, + async run(context) { + const response = await kitClient.request<{ subscriber: Subscriber }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.PUT, + path: '/unsubscribe', + body: { email: kitCommon.email({ value: context.propsValue.email, label: 'Email' }) }, + }); + return response.body.subscriber; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/update-broadcast.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/update-broadcast.ts new file mode 100644 index 000000000000..5671728912d9 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/update-broadcast.ts @@ -0,0 +1,110 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Broadcast } from '../../common/types'; +import { kitBroadcastOutputSchema } from '../../output-schemas'; + +export const kitUpdateBroadcast = createAction({ + auth: convertkitAuth, + name: 'kit_update_broadcast', + classification: 'WRITE', + outputSchema: kitBroadcastOutputSchema, + displayName: 'Update Broadcast', + description: 'Change fields on an existing broadcast. Fields left empty are not changed.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the fields you pass on an existing broadcast, such as subject or content; everything left empty keeps its current value. Setting Send At schedules a real send to subscribers, and Publish As Web Post changes its web visibility, so only set them when the user asked. Get the broadcast ID from List Broadcasts.', + idempotent: true, + }, + props: { + broadcast_id: kitProps.id('Broadcast ID', 'The broadcast ID, from List Broadcasts.'), + subject: Property.ShortText({ + displayName: 'Subject', + description: 'New email subject line.', + required: false, + }), + content: Property.LongText({ + displayName: 'Content', + description: 'New email body. Plain text or simple HTML (p, h1, img, a).', + required: false, + }), + description: Property.ShortText({ + displayName: 'Internal Description', + description: 'New internal note, not shown to subscribers.', + required: false, + }), + email_address: Property.ShortText({ + displayName: 'From Email Address', + description: 'New sending address. It must be a confirmed sender in Kit.', + required: false, + }), + email_layout_template: Property.ShortText({ + displayName: 'Email Template Name', + description: 'New email template name.', + required: false, + }), + send_at: Property.DateTime({ + displayName: 'Send At', + description: + 'Schedule a real send at this ISO 8601 date-time, which must be in the future. Leave empty to keep the current schedule.', + required: false, + }), + public: Property.StaticDropdown({ + displayName: 'Publish As Web Post', + description: 'Change web post visibility. Leave empty to keep it as it is.', + required: false, + options: { + options: [ + { label: 'Publish as web post', value: 'true' }, + { label: 'Do not publish as web post', value: 'false' }, + ], + }, + }), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'New publication time shown on the web post.', + required: false, + }), + thumbnail_url: Property.ShortText({ + displayName: 'Thumbnail URL', + description: 'New image URL for the web post thumbnail.', + required: false, + }), + thumbnail_alt: Property.ShortText({ + displayName: 'Thumbnail Alt Text', + description: 'New alt text for the web post thumbnail.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = kitCommon.compact({ + subject: props.subject, + content: props.content, + description: props.description, + email_address: + props.email_address === undefined || props.email_address === null || props.email_address.trim() === '' + ? undefined + : kitCommon.email({ value: props.email_address, label: 'From Email Address' }), + email_layout_template: props.email_layout_template, + send_at: kitCommon.toDateTime({ value: props.send_at, label: 'Send At', future: true }), + public: props.public === undefined || props.public === null ? undefined : props.public === 'true', + published_at: kitCommon.toDateTime({ value: props.published_at, label: 'Published At' }), + thumbnail_url: props.thumbnail_url, + thumbnail_alt: props.thumbnail_alt, + }); + if (Object.keys(body).length === 0) { + throw new Error('Set at least one field to update.'); + } + const response = await kitClient.request<{ broadcast: Broadcast }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.PUT, + path: `/broadcasts/${kitCommon.id({ value: props.broadcast_id, label: 'Broadcast ID' })}`, + body, + }); + return response.body.broadcast; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/update-custom-field.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/update-custom-field.ts new file mode 100644 index 000000000000..e10179c028c0 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/update-custom-field.ts @@ -0,0 +1,69 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { CustomField } from '../../common/types'; +import { kitUpdateCustomFieldOutputSchema } from '../../output-schemas'; + +export const kitUpdateCustomField = createAction({ + auth: convertkitAuth, + name: 'kit_update_custom_field', + classification: 'WRITE', + outputSchema: kitUpdateCustomFieldOutputSchema, + displayName: 'Update Custom Field', + description: 'Rename a custom subscriber field.', + audience: 'ai', + aiMetadata: { + description: + 'Renames a custom field by ID and returns the updated field. Only the label changes: the field key used in subscriber fields and liquid tags stays the same, and stored subscriber values are kept. If reading the field back fails after the rename, it still succeeds and returns refreshed: false with the reason.', + idempotent: true, + }, + props: { + custom_field_id: kitProps.id('Custom Field ID', 'The field ID, from List Custom Fields.'), + label: Property.ShortText({ + displayName: 'New Label', + description: 'The new label. Must be unique on the account.', + required: true, + }), + }, + async run(context) { + const fieldId = kitCommon.id({ value: context.propsValue.custom_field_id, label: 'Custom Field ID' }); + const label = context.propsValue.label.trim(); + if (!label) { + throw new Error('New Label cannot be empty.'); + } + await kitClient.request({ + apiSecret: context.auth.secret_text, + method: HttpMethod.PUT, + path: `/custom_fields/${fieldId}`, + body: { label }, + }); + try { + const list = await kitClient.request<{ custom_fields: CustomField[] }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.GET, + path: '/custom_fields', + }); + const updated = (list.body.custom_fields ?? []).find((field) => String(field.id) === fieldId); + if (updated) { + return { ...updated, refreshed: true }; + } + return { + id: Number(fieldId), + label, + refreshed: false, + refresh_error: 'The rename succeeded, but the field was not in the refreshed custom field list.', + }; + } catch (error) { + return { + id: Number(fieldId), + label, + refreshed: false, + refresh_error: `The rename succeeded, but reading the field back failed: ${ + error instanceof Error ? error.message : 'unknown error' + }`, + }; + } + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/ai/update-subscriber.ts b/packages/pieces/community/convertkit/src/lib/actions/ai/update-subscriber.ts new file mode 100644 index 000000000000..5984d5e66597 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/actions/ai/update-subscriber.ts @@ -0,0 +1,56 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { convertkitAuth } from '../../auth'; +import { kitClient, kitCommon } from '../../common/client'; +import { kitProps } from '../../common/ai-props'; +import { Subscriber } from '../../common/types'; +import { kitSubscriberOutputSchema } from '../../output-schemas'; + +export const kitUpdateSubscriber = createAction({ + auth: convertkitAuth, + name: 'kit_update_subscriber', + classification: 'WRITE', + outputSchema: kitSubscriberOutputSchema, + displayName: 'Update Subscriber', + description: 'Update the email, first name or custom fields of a subscriber.', + audience: 'ai', + aiMetadata: { + description: + 'Updates a subscriber by ID. Only the inputs you supply are sent, so omitted ones keep their stored value; an empty string in Custom Fields clears that field. It cannot change subscription state; use Unsubscribe Subscriber for that.', + idempotent: true, + }, + props: { + subscriber_id: kitProps.id('Subscriber ID', 'The subscriber ID, from List Subscribers.'), + email_address: Property.ShortText({ + displayName: 'New Email Address', + description: 'Change the subscriber email address.', + required: false, + }), + first_name: kitProps.firstName, + fields: kitProps.fields, + }, + async run(context) { + const { subscriber_id, email_address, first_name, fields } = context.propsValue; + const body: Record = {}; + if (email_address !== undefined && email_address !== null && email_address !== '') { + body['email_address'] = kitCommon.email({ value: email_address, label: 'New Email Address' }); + } + if (first_name !== undefined && first_name !== null && first_name !== '') { + body['first_name'] = first_name; + } + const customFields = kitCommon.toFields(fields); + if (customFields) { + body['fields'] = customFields; + } + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one of New Email Address, First Name or Custom Fields.'); + } + const response = await kitClient.request<{ subscriber: Subscriber }>({ + apiSecret: context.auth.secret_text, + method: HttpMethod.PUT, + path: `/subscribers/${kitCommon.id({ value: subscriber_id, label: 'Subscriber ID' })}`, + body, + }); + return response.body.subscriber; + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/actions/broadcasts.ts b/packages/pieces/community/convertkit/src/lib/actions/broadcasts.ts index f71d6b7b354f..e08c2e145423 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/broadcasts.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/broadcasts.ts @@ -1,10 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; -import { convertkitAuth } from '../..'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { broadcastId, broadcastPageNumber, @@ -22,14 +19,21 @@ import { import { Broadcast } from '../common/types'; import { BROADCASTS_API_ENDPOINT } from '../common/constants'; import { buildQueryParams, fetchBroadcasts } from '../common/service'; +import { + kitBroadcastListOutputSchema, + kitBroadcastOutputSchema, + kitBroadcastStatsOutputSchema, + kitRequestStatusOutputSchema, +} from '../output-schemas'; export const listBroadcasts = createAction({ auth: convertkitAuth, name: 'broadcasts_list_broadcasts', classification: 'SEARCH', + outputSchema: kitBroadcastListOutputSchema, displayName: 'List Broadcasts', description: 'List all broadcasts', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves a page of broadcasts (one-off emails) in the ConvertKit account, 50 per page. Use it to find a broadcast ID before getting, updating, or deleting one. Read-only and idempotent; pass a page number to fetch beyond the first 50.', @@ -48,9 +52,10 @@ export const createBroadcast = createAction({ auth: convertkitAuth, name: 'broadcasts_create_broadcast', classification: 'WRITE', + outputSchema: kitBroadcastOutputSchema, displayName: 'Create Broadcast', description: 'Create a new broadcast', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new email broadcast (one-off email), optionally scheduled via the send-at field; without it the broadcast is saved unsent. Not idempotent — every call creates another broadcast, so do not retry blindly.', @@ -105,7 +110,7 @@ export const createBroadcast = createAction({ 'Content-Type': 'application/json', }, }; - const response = await httpClient.sendRequest<{ broadcast: Broadcast }>( + const response = await kitHttp.sendRequest<{ broadcast: Broadcast }>( request ); if (response.status !== 201) { @@ -119,9 +124,10 @@ export const getBroadcastById = createAction({ auth: convertkitAuth, name: 'broadcasts_get_broadcast', classification: 'READ', + outputSchema: kitBroadcastOutputSchema, displayName: 'Get Broadcast', description: 'Get a broadcast', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetches a single broadcast by its ID, returning its content and settings. Use when the broadcast ID is already known (e.g. from List Broadcasts); for delivery metrics use Broadcast Stats instead. Read-only and idempotent.', @@ -139,7 +145,7 @@ export const getBroadcastById = createAction({ queryParams: buildQueryParams(context.auth.secret_text), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ broadcast: Broadcast }>( + const response = await kitHttp.sendRequest<{ broadcast: Broadcast }>( request ); if (response.status !== 200) { @@ -153,9 +159,10 @@ export const updateBroadcast = createAction({ auth: convertkitAuth, name: 'broadcasts_update_broadcast', classification: 'WRITE', + outputSchema: kitBroadcastOutputSchema, displayName: 'Update Broadcast', description: 'Update a broadcast', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates the content, subject, scheduling, or other fields of an existing broadcast by broadcast ID. Idempotent — repeating the same update leaves the broadcast in the same state. Requires a valid broadcast ID from List Broadcasts.', @@ -214,7 +221,7 @@ export const updateBroadcast = createAction({ 'Content-Type': 'application/json', }, }; - const response = await httpClient.sendRequest<{ broadcast: Broadcast }>( + const response = await kitHttp.sendRequest<{ broadcast: Broadcast }>( request ); if (response.status !== 200) { @@ -228,9 +235,10 @@ export const broadcastStats = createAction({ auth: convertkitAuth, name: 'broadcasts_broadcast_stats', classification: 'READ', + outputSchema: kitBroadcastStatsOutputSchema, displayName: 'Broadcast Stats', description: 'Get broadcast stats', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves delivery and engagement statistics (recipients, opens, clicks, unsubscribes) for one broadcast by ID. Pick this over Get Broadcast when performance metrics are needed rather than content. Read-only and idempotent.', @@ -248,7 +256,7 @@ export const broadcastStats = createAction({ queryParams: buildQueryParams(context.auth.secret_text), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ broadcast: Broadcast }>( + const response = await kitHttp.sendRequest<{ broadcast: Broadcast }>( request ); if (response.status !== 200) { @@ -262,9 +270,10 @@ export const deleteBroadcast = createAction({ auth: convertkitAuth, name: 'broadcasts_delete_broadcast', classification: 'DESTRUCTIVE', + outputSchema: kitRequestStatusOutputSchema, displayName: 'Delete Broadcast', description: 'Delete a broadcast', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Permanently deletes a broadcast by ID. Destructive and not retry-safe — a repeat call fails once the broadcast is gone, so confirm the ID via List Broadcasts first.', @@ -286,7 +295,7 @@ export const deleteBroadcast = createAction({ body, method: HttpMethod.DELETE, }; - const response = await httpClient.sendRequest<{ broadcast: Broadcast }>( + const response = await kitHttp.sendRequest<{ broadcast: Broadcast }>( request ); if (response.status !== 204) { diff --git a/packages/pieces/community/convertkit/src/lib/actions/custom-fields.ts b/packages/pieces/community/convertkit/src/lib/actions/custom-fields.ts index 05ce7aa53a8d..3dcfd0996f67 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/custom-fields.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/custom-fields.ts @@ -1,22 +1,25 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; -import { convertkitAuth } from '../..'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { fieldsArray, label, new_label } from '../common/custom-fields'; import { CustomField } from '../common/types'; import { CUSTOM_FIELDS_API_ENDPOINT } from '../common/constants'; import { fetchCustomFields } from '../common/service'; +import { + kitCustomFieldListOutputSchema, + kitCustomFieldOutputSchema, + kitRequestStatusOutputSchema, +} from '../output-schemas'; export const listFields = createAction({ auth: convertkitAuth, name: 'custom_fields_list_fields', classification: 'SEARCH', + outputSchema: kitCustomFieldListOutputSchema, displayName: 'List Custom Fields', description: 'Returns a list of all custom fields', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists every custom field defined on the account, including each field id, key, and label. Use it to discover field keys before setting custom field values on subscribers. Takes no inputs; read-only and idempotent.', @@ -32,9 +35,10 @@ export const createField = createAction({ auth: convertkitAuth, name: 'custom_fields_create_field', classification: 'WRITE', + outputSchema: kitCustomFieldOutputSchema, displayName: 'Create Custom Field', description: 'Create a new custom field', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates one or more new custom fields from an array of labels (ConvertKit derives each field key from its label). Not idempotent — repeating the call creates duplicate fields, so check List Custom Fields first.', @@ -57,9 +61,7 @@ export const createField = createAction({ body, }; - const response = await httpClient.sendRequest<{ - custom_field: CustomField; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 201) { throw new Error(`Error creating field: ${response.status}`); @@ -72,9 +74,10 @@ export const updateField = createAction({ auth: convertkitAuth, name: 'custom_fields_update_field', classification: 'WRITE', + outputSchema: kitRequestStatusOutputSchema, displayName: 'Custom Fields: Update Field', description: 'Update a custom field', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Renames an existing custom field, selected by its current label, to a new label. Not idempotent — once the rename succeeds the old label no longer matches, so a retry with the same inputs fails.', @@ -100,9 +103,7 @@ export const updateField = createAction({ body, }; - const response = await httpClient.sendRequest<{ - custom_field: CustomField; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 204) { throw new Error(`Error updating field: ${response.status}`); @@ -115,9 +116,10 @@ export const deleteField = createAction({ auth: convertkitAuth, name: 'custom_fields_delete_field', classification: 'DESTRUCTIVE', + outputSchema: kitRequestStatusOutputSchema, displayName: 'Custom Fields: Delete Field', description: 'Delete a custom field', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Deletes a custom field by label, removing it and its stored values from all subscribers. Destructive and not retry-safe — a repeat call fails once the field is gone.', @@ -141,9 +143,7 @@ export const deleteField = createAction({ body, }; - const response = await httpClient.sendRequest<{ - custom_field: CustomField; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 204) { throw new Error(`Error deleting field: ${response.status}`); diff --git a/packages/pieces/community/convertkit/src/lib/actions/forms.ts b/packages/pieces/community/convertkit/src/lib/actions/forms.ts index 14dda7082515..ce9cf6233256 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/forms.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/forms.ts @@ -1,32 +1,34 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; import { Subscription } from '../common/types'; -import { convertkitAuth } from '../..'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { formId } from '../common/forms'; import { subscriberEmail, subscriberFirstName } from '../common/subscribers'; import { allFields } from '../common/custom-fields'; import { FORMS_API_ENDPOINT } from '../common/constants'; import { buildQueryParams, fetchForms } from '../common/service'; import { tags } from '../common/tags'; +import { + kitFormListOutputSchema, + kitSubscriptionOutputSchema, +} from '../output-schemas'; export const listForms = createAction({ auth: convertkitAuth, name: 'forms_list_forms', classification: 'SEARCH', + outputSchema: kitFormListOutputSchema, displayName: 'List Forms', description: 'Returns a list of all forms', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists all forms and landing pages in the account with their IDs and names. Use it to find a form ID before adding subscribers to a form or listing form subscriptions. Takes no inputs; read-only and idempotent.', idempotent: true, }, props: {}, - run(context) { + run(context) { return fetchForms(context.auth.secret_text); }, }); @@ -42,9 +44,10 @@ export const addSubscriberToForm = createAction({ auth: convertkitAuth, name: 'forms_add_subscriber_to_form', classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, displayName: 'Add Subscriber To Form', description: 'Add a subscriber to a form', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Subscribes an email address to a specific form, optionally setting first name, tags, and custom field values. This is the standard way to add a new subscriber in ConvertKit. Effectively idempotent — re-subscribing the same email to the same form upserts rather than creating a duplicate.', @@ -76,7 +79,7 @@ export const addSubscriberToForm = createAction({ body, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscription: Subscription; }>(request); @@ -93,7 +96,7 @@ export const listFormSubscriptions = createAction({ classification: 'SEARCH', displayName: 'List Form Subscriptions', description: 'List form subscriptions', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists the subscriptions for one form by form ID, one entry per subscriber who signed up through it. Use it to audit who joined via a given form. Read-only and idempotent.', @@ -111,7 +114,7 @@ export const listFormSubscriptions = createAction({ method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscriptions: Subscription[]; }>(request); diff --git a/packages/pieces/community/convertkit/src/lib/actions/purchases.ts b/packages/pieces/community/convertkit/src/lib/actions/purchases.ts index 17b3385ed78d..167b46fc5d06 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/purchases.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/purchases.ts @@ -1,10 +1,11 @@ import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; -import { convertkitAuth } from '../..'; + kitPurchaseListOutputSchema, + kitPurchaseOutputSchema, +} from '../output-schemas'; import { purchaseId, purchasesPageNumber, @@ -34,9 +35,10 @@ export const listPurchases = createAction({ auth: convertkitAuth, name: 'purchases_list_purchases', classification: 'SEARCH', + outputSchema: kitPurchaseListOutputSchema, displayName: 'List Purchases', description: 'Returns a list of all purchases', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves a paginated list of all purchase records in the account; pass a page number to go beyond the first page. For purchases scoped to one subscriber, product, form, or sequence, prefer the dedicated filtered list actions. Read-only and idempotent.', @@ -55,9 +57,10 @@ export const getPurchaseById = createAction({ auth: convertkitAuth, name: 'purchases_get_purchase_by_id', classification: 'READ', + outputSchema: kitPurchaseOutputSchema, displayName: 'Get Purchase By Id', description: 'Returns data for a single purchase', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetches one purchase record by its ConvertKit purchase ID (not the external transaction ID). Use List Purchases first if only the transaction details are known. Read-only and idempotent.', @@ -76,9 +79,7 @@ export const getPurchaseById = createAction({ queryParams: buildQueryParams(context.auth.secret_text), }; - const response = await httpClient.sendRequest<{ - purchase: Purchase; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 200) { throw new Error(`Error fetching purchase: ${response.status}`); @@ -99,6 +100,7 @@ export const createSinglePurchase = createAction({ auth: convertkitAuth, name: 'purchases_create_purchase', classification: 'WRITE', + outputSchema: kitPurchaseOutputSchema, displayName: 'Create Purchase', description: 'Creates a new purchase', audience: 'both', @@ -162,9 +164,7 @@ export const createSinglePurchase = createAction({ body, }; - const response = await httpClient.sendRequest<{ - purchase: Purchase; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 201) { throw new Error(`Error creating purchase: ${response.status}`); @@ -178,6 +178,7 @@ export const createPurchases = createAction({ auth: convertkitAuth, name: 'purchases_create_multiple_purchases', classification: 'WRITE', + outputSchema: kitPurchaseOutputSchema, displayName: 'Create Multiple Purchases', description: 'Creates multiple purchases', audience: 'both', @@ -241,9 +242,7 @@ export const createPurchases = createAction({ body, }; - const response = await httpClient.sendRequest<{ - purchase: Purchase; - }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 201) { throw new Error(`Error creating purchase: ${response.status}`); @@ -283,7 +282,7 @@ export const listPurchasesForSubscriber = createAction({ }), }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ purchases: Purchase[]; }>(request); diff --git a/packages/pieces/community/convertkit/src/lib/actions/sequences.ts b/packages/pieces/community/convertkit/src/lib/actions/sequences.ts index 5f20f66a0dc2..5802c57c3a99 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/sequences.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/sequences.ts @@ -1,25 +1,28 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; import { Sequence } from '../common/types'; -import { convertkitAuth } from '../..'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { sequenceIdDropdown } from '../common/sequences'; import { subscriberEmail, subscriberFirstName } from '../common/subscribers'; import { allFields } from '../common/custom-fields'; import { tags } from '../common/tags'; import { SEQUENCES_API_ENDPOINT } from '../common/constants'; import { buildQueryParams, fetchSequences } from '../common/service'; +import { + kitSequenceListOutputSchema, + kitSubscriptionListOutputSchema, + kitSubscriptionOutputSchema, +} from '../output-schemas'; export const listSequences = createAction({ auth: convertkitAuth, name: 'sequences_list_sequences', classification: 'SEARCH', + outputSchema: kitSequenceListOutputSchema, displayName: 'List Sequences', description: 'Returns a list of all sequences', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists all sequences (automated email courses) in the account with their IDs and names. Use it to find a sequence ID before enrolling subscribers or listing enrollments. Takes no inputs; read-only and idempotent.', @@ -35,9 +38,10 @@ export const addSubscriberToSequence = createAction({ auth: convertkitAuth, name: 'sequences_add_subscriber_to_sequence', classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, displayName: 'Add Subscriber To Sequence', description: 'Add a subscriber to a sequence', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Enrolls an email address in a sequence so it starts receiving that automated email course, optionally setting first name, tags, and custom field values. Effectively idempotent — re-subscribing the same email to the same sequence upserts instead of duplicating the enrollment.', @@ -68,7 +72,7 @@ export const addSubscriberToSequence = createAction({ body, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscription: Sequence; }>(request); @@ -85,9 +89,10 @@ export const listSubscriptionsToSequence = createAction({ auth: convertkitAuth, name: 'sequences_list_subscriptions_to_sequence', classification: 'SEARCH', + outputSchema: kitSubscriptionListOutputSchema, displayName: 'List Subscriptions To Sequence', description: 'List all subscriptions to a sequence', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists all subscriber enrollments for one sequence by sequence ID. Use List Sequences first to find the ID. Read-only and idempotent.', @@ -105,7 +110,7 @@ export const listSubscriptionsToSequence = createAction({ queryParams: buildQueryParams(context.auth.secret_text), }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscriptions: Sequence[]; }>(request); diff --git a/packages/pieces/community/convertkit/src/lib/actions/subscribers.ts b/packages/pieces/community/convertkit/src/lib/actions/subscribers.ts index 2a715aa5ff4b..f4ed5fdb9d99 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/subscribers.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/subscribers.ts @@ -1,11 +1,8 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; import { Subscriber } from '../common/types'; -import { convertkitAuth } from '../..'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { subscriberId, subscriberEmail, @@ -30,14 +27,20 @@ import { fetchSubscriberByEmail, fetchSubscribedTags, } from '../common/service'; +import { + kitSubscriberListOutputSchema, + kitSubscriberOutputSchema, + kitTagListOutputSchema, +} from '../output-schemas'; export const getSubscriberById = createAction({ auth: convertkitAuth, name: 'subscribers_get_subscriber_by_id', classification: 'READ', + outputSchema: kitSubscriberOutputSchema, displayName: 'Get Subscriber By Id', description: 'Returns data for a single subscriber', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetches one subscriber record (email, name, state, custom fields) by numeric subscriber ID. Use Get Subscriber By Email when only an address is known. Read-only and idempotent.', @@ -56,9 +59,10 @@ export const getSubscriberByEmail = createAction({ auth: convertkitAuth, name: 'subscribers_get_subscriber_by_email', classification: 'READ', + outputSchema: kitSubscriberOutputSchema, displayName: 'Get Subscriber By Email', description: 'Returns data for a single subscriber', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Looks up a single subscriber by email address and returns the full record including the numeric subscriber ID. Prefer this over Get Subscriber By Id when the ID is unknown. Read-only and idempotent.', @@ -77,9 +81,10 @@ export const listSubscribers = createAction({ auth: convertkitAuth, name: 'subscribers_list_subscribers', classification: 'SEARCH', + outputSchema: kitSubscriberListOutputSchema, displayName: 'List Subscribers', description: 'Returns a list of all subscribers', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists subscribers with optional filters (created/updated date ranges, email address), paging, and sorting. Use it for bulk audits or fuzzy searches; for one known subscriber prefer the Get Subscriber actions. Read-only and idempotent.', @@ -124,7 +129,7 @@ export const listSubscribers = createAction({ }), }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscribers: Subscriber[]; }>(request); @@ -140,9 +145,10 @@ export const updateSubscriber = createAction({ auth: convertkitAuth, name: 'subscribers_update_subscriber', classification: 'WRITE', + outputSchema: kitSubscriberOutputSchema, displayName: 'Update Subscriber', description: 'Update a subscriber', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates a subscriber email address, first name, or custom field values by numeric subscriber ID. Idempotent — repeating the same update leaves the subscriber in the same state. It cannot change subscription status; use Unsubscribe Subscriber for that.', @@ -172,7 +178,7 @@ export const updateSubscriber = createAction({ body, }; - const response = await httpClient.sendRequest<{ subscriber: Subscriber }>( + const response = await kitHttp.sendRequest<{ subscriber: Subscriber }>( request ); @@ -188,9 +194,10 @@ export const unsubscribeSubscriber = createAction({ auth: convertkitAuth, name: 'subscribers_unsubscribe_subscriber', classification: 'DESTRUCTIVE', + outputSchema: kitSubscriberOutputSchema, displayName: 'Unsubscribe Subscriber', description: 'Unsubscribe a subscriber', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Unsubscribes the given email address from all emails in the account; this is the only removal mechanism, as there is no delete-subscriber action. Treated as non-idempotent — a retry may error once the address is already unsubscribed, though the end state is the same.', @@ -211,7 +218,7 @@ export const unsubscribeSubscriber = createAction({ body, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscriber: Subscriber; }>(request); @@ -227,9 +234,10 @@ export const listTagsBySubscriberId = createAction({ auth: convertkitAuth, name: 'subscribers_list_tags_by_subscriber_id', classification: 'SEARCH', + outputSchema: kitTagListOutputSchema, displayName: 'List Tags By Subscriber Id', description: 'Returns a list of all subscribed tags', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists all tags currently applied to a subscriber, looked up by numeric subscriber ID. Use List Tags By Email when only an address is known. Read-only and idempotent.', @@ -248,9 +256,10 @@ export const listSubscriberTagsByEmail = createAction({ auth: convertkitAuth, name: 'subscribers_list_tags_by_email', classification: 'SEARCH', + outputSchema: kitTagListOutputSchema, displayName: 'List Tags By Email', description: 'Returns a list of all subscribed tags', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists all tags applied to a subscriber, looked up by email address (the email is resolved to a subscriber ID internally). Read-only and idempotent.', diff --git a/packages/pieces/community/convertkit/src/lib/actions/tags.ts b/packages/pieces/community/convertkit/src/lib/actions/tags.ts index 7964be6e93b6..4c2e86f8a69f 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/tags.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/tags.ts @@ -1,10 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; -import { - httpClient, - HttpMethod, - HttpRequest, -} from '@activepieces/pieces-common'; -import { convertkitAuth } from '../..'; +import { HttpMethod, HttpRequest } from '@activepieces/pieces-common'; +import { convertkitAuth } from '../auth'; +import { kitHttp } from '../common/http'; import { tag, tagsRequired, @@ -22,19 +19,29 @@ import { } from '../common/subscribers'; import { Tag } from '../common/types'; import { allFields } from '../common/custom-fields'; -import { TAGS_API_ENDPOINT } from '../common/constants'; +import { + SUBSCRIBERS_API_ENDPOINT, + TAGS_API_ENDPOINT, +} from '../common/constants'; import { buildQueryParams, fetchTags } from '../common/service'; +import { + kitSubscriptionListOutputSchema, + kitSubscriptionOutputSchema, + kitTagListOutputSchema, + kitTagOutputSchema, +} from '../output-schemas'; export const listTags = createAction({ auth: convertkitAuth, name: 'tags_list_tags', classification: 'SEARCH', + outputSchema: kitTagListOutputSchema, displayName: 'List Tags', description: 'Returns a list of all tags', - audience: 'both', + audience: 'human', aiMetadata: { description: - 'Lists every tag in the account with its ID and name. Use it to find a tag ID before tagging or untagging subscribers, or before listing a tag\'s subscriptions. Takes no inputs; read-only and idempotent.', + "Lists every tag in the account with its ID and name. Use it to find a tag ID before tagging or untagging subscribers, or before listing a tag's subscriptions. Takes no inputs; read-only and idempotent.", idempotent: true, }, props: {}, @@ -47,9 +54,10 @@ export const createTag = createAction({ auth: convertkitAuth, name: 'tags_create_tag', classification: 'WRITE', + outputSchema: kitTagOutputSchema, displayName: 'Create Tag', description: 'Create a tag', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new tag with the given name. Not idempotent — calling it again can create a duplicate, so check List Tags for an existing tag first.', @@ -72,7 +80,7 @@ export const createTag = createAction({ body, }; - const response = await httpClient.sendRequest<{ tag: Tag }>(request); + const response = await kitHttp.sendRequest(request); if (response.status !== 201) { throw new Error(`Error creating tag: ${response.status}`); @@ -89,9 +97,10 @@ export const tagSubscriber = createAction({ auth: convertkitAuth, name: 'tags_tag_subscriber', classification: 'WRITE', + outputSchema: kitSubscriptionOutputSchema, displayName: 'Tag Subscriber', description: 'Tag a subscriber', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Applies one or more existing tags to a subscriber by email address (subscribing the email to the first tag and attaching the rest), optionally setting first name and custom fields; at least one tag is required. Effectively idempotent — re-applying the same tags converges to the same state.', @@ -127,7 +136,7 @@ export const tagSubscriber = createAction({ body, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscription: Tag; }>(request); @@ -143,9 +152,10 @@ export const removeTagFromSubscriberByEmail = createAction({ auth: convertkitAuth, name: 'tags_remove_tag_from_subscriber_by_email', classification: 'WRITE', + outputSchema: kitTagOutputSchema, displayName: 'Remove Tag From Subscriber By Email', description: 'Remove a tag from a subscriber by email', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Removes a single tag from a subscriber identified by email address. Use the By Id variant when the numeric subscriber ID is already known. Treated as non-idempotent — a retry may error once the tag is already removed, though the end state is the same.', @@ -170,7 +180,7 @@ export const removeTagFromSubscriberByEmail = createAction({ body, }; - const response = await httpClient.sendRequest<{ subscriber: Tag }>(request); + const response = await kitHttp.sendRequest<{ subscriber: Tag }>(request); if (response.status !== 200) { throw new Error(`Error removing tag from subscriber: ${response.status}`); @@ -184,9 +194,10 @@ export const removeTagFromSubscriberById = createAction({ auth: convertkitAuth, name: 'tags_remove_tag_from_subscriber_by_id', classification: 'WRITE', + outputSchema: kitTagOutputSchema, displayName: 'Remove Tag From Subscriber By Id', description: 'Remove a tag from a subscriber by id', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Removes a single tag from a subscriber identified by numeric subscriber ID. Use the By Email variant when only an address is known. Treated as non-idempotent — a retry may error once the tag is already removed.', @@ -198,20 +209,15 @@ export const removeTagFromSubscriberById = createAction({ }, async run(context) { const { subscriberId, tagId } = context.propsValue; - const url = `${TAGS_API_ENDPOINT}/${tagId}/unsubscribe`; - - const body = { - id: subscriberId, - api_secret: context.auth.secret_text, - }; + const url = `${SUBSCRIBERS_API_ENDPOINT}/${subscriberId}/tags/${tagId}`; const request: HttpRequest = { url, - method: HttpMethod.POST, - body, + method: HttpMethod.DELETE, + queryParams: buildQueryParams(context.auth.secret_text), }; - const response = await httpClient.sendRequest<{ subscriber: Tag }>(request); + const response = await kitHttp.sendRequest<{ subscriber: Tag }>(request); if (response.status !== 200) { throw new Error(`Error removing tag from subscriber: ${response.status}`); @@ -225,9 +231,10 @@ export const listSubscriptionsToATag = createAction({ auth: convertkitAuth, name: 'tags_list_subscriptions_to_tag', classification: 'SEARCH', + outputSchema: kitSubscriptionListOutputSchema, displayName: 'List Subscriptions To Tag', description: 'List all subscriptions to a tag', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Lists the subscribers subscribed to a specific tag, with paging, sort order, and subscriber-state filtering. Use List Tags first to find the tag ID. Read-only and idempotent.', @@ -253,7 +260,7 @@ export const listSubscriptionsToATag = createAction({ }), }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ subscriptions: Tag[]; }>(request); diff --git a/packages/pieces/community/convertkit/src/lib/actions/webhooks.ts b/packages/pieces/community/convertkit/src/lib/actions/webhooks.ts index 26cf37b5e580..a1efc8b63cdd 100644 --- a/packages/pieces/community/convertkit/src/lib/actions/webhooks.ts +++ b/packages/pieces/community/convertkit/src/lib/actions/webhooks.ts @@ -1,5 +1,5 @@ import { createAction } from '@activepieces/pieces-framework'; -import { convertkitAuth } from '../..'; +import { convertkitAuth } from '../auth'; import { targetUrl, event, @@ -10,14 +10,16 @@ import { createWebhook as createWebhookAction, removeWebhook as removeWebhookAction, } from '../common/service'; +import { kitWebhookOutputSchema } from '../output-schemas'; export const createWebhook = createAction({ auth: convertkitAuth, name: 'create_webhook', classification: 'WRITE', + outputSchema: kitWebhookOutputSchema, displayName: 'Add Webhook', description: 'Create a webhook automation', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Registers a webhook automation that POSTs to a target URL whenever the chosen ConvertKit event fires (e.g. subscriber activated, tag added); some events also need an event parameter such as a tag or form ID. Not idempotent — each call registers another webhook, so keep the returned rule ID for later deletion.', @@ -49,7 +51,7 @@ export const deleteWebhook = createAction({ classification: 'DESTRUCTIVE', displayName: 'Delete Webhook', description: 'Delete a webhook automation', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Deletes a previously registered webhook automation by its webhook rule ID (returned when the webhook was created). Not retry-safe — a repeat call fails once the webhook is gone.', diff --git a/packages/pieces/community/convertkit/src/lib/auth.ts b/packages/pieces/community/convertkit/src/lib/auth.ts new file mode 100644 index 000000000000..99b83d9d52de --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/auth.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { PieceAuth } from '@activepieces/pieces-framework'; +import { CONVERTKIT_API_URL } from './common/constants'; +import { kitErrorStatus, kitHttp } from './common/http'; + +export const convertkitAuth = PieceAuth.SecretText({ + displayName: 'API Secret', + description: + 'Enter your API Secret key. In Kit, go to Settings > Developer, open the V3 Key section and copy the API Secret.', + required: true, + validate: async ({ auth }) => { + try { + await kitHttp.sendRequest({ + method: HttpMethod.GET, + url: `${CONVERTKIT_API_URL}/account`, + queryParams: { api_secret: auth }, + }); + return { valid: true }; + } catch (error) { + const status = kitErrorStatus(error); + if (status === 401 || status === 403) { + return { + valid: false, + error: 'Kit rejected this API Secret. Use the V3 API Secret from Kit Settings > Developer, not the API Key or a V4 key.', + }; + } + return { + valid: false, + error: `Could not check the API Secret with Kit: ${error instanceof Error ? error.message : 'unknown error'}`, + }; + } + }, +}); diff --git a/packages/pieces/community/convertkit/src/lib/common/ai-props.ts b/packages/pieces/community/convertkit/src/lib/common/ai-props.ts new file mode 100644 index 000000000000..72875914863c --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/common/ai-props.ts @@ -0,0 +1,64 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const kitProps = { + page: (description: string) => + Property.Number({ + displayName: 'Page', + description, + required: false, + defaultValue: 1, + }), + sortOrder: (description: string) => + Property.StaticDropdown({ + displayName: 'Sort Order', + description, + required: false, + options: { + options: [ + { label: 'Ascending (oldest first)', value: 'asc' }, + { label: 'Descending (newest first)', value: 'desc' }, + ], + }, + }), + subscriberState: Property.StaticDropdown({ + displayName: 'Subscriber State', + description: + 'Return only active or only cancelled subscriptions. Leave empty for the Kit default.', + required: false, + options: { + options: [ + { label: 'Active', value: 'active' }, + { label: 'Cancelled', value: 'cancelled' }, + ], + }, + }), + email: (description: string) => + Property.ShortText({ + displayName: 'Email', + description, + required: true, + }), + firstName: Property.ShortText({ + displayName: 'First Name', + description: 'The first name to store on the subscriber.', + required: false, + }), + fields: Property.Json({ + displayName: 'Custom Fields', + description: + 'A JSON object of custom field key to value, e.g. {"last_name": "Snow"}. Keys must already exist; get them from List Custom Fields.', + required: false, + }), + tagIds: (displayName: string, description: string) => + Property.Array({ + displayName, + description, + required: false, + }), + id: (displayName: string, description: string) => + Property.ShortText({ + displayName, + description, + required: true, + }), +}; diff --git a/packages/pieces/community/convertkit/src/lib/common/client.ts b/packages/pieces/community/convertkit/src/lib/common/client.ts new file mode 100644 index 000000000000..f1d34b19ee51 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/common/client.ts @@ -0,0 +1,158 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { CONVERTKIT_API_URL } from './constants'; +import { buildQueryParams } from './service'; +import { kitHttp } from './http'; + +type QueryValue = string | number | undefined | null; + +export const kitClient = { + async request({ + apiSecret, + method, + path, + query, + body, + }: { + apiSecret: string; + method: HttpMethod; + path: string; + query?: Record; + body?: Record; + }): Promise<{ status: number; body: T }> { + const hasBody = method === HttpMethod.POST || method === HttpMethod.PUT; + const response = await kitHttp.sendRequest({ + method, + url: `${CONVERTKIT_API_URL}${path}`, + queryParams: buildQueryParams(apiSecret, query ?? {}), + body: hasBody ? body ?? {} : undefined, + }); + return { status: response.status, body: response.body }; + }, +}; + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const kitCommon = { + id({ value, label }: { value: unknown; label: string }): string { + const text = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''; + if (!/^\d+$/.test(text)) { + throw new Error(`${label} must be a numeric Kit ID, got "${String(value ?? '')}".`); + } + return text; + }, + page(value: unknown): number { + if (value === undefined || value === null || value === '') { + return 1; + } + const page = Number(value); + if (!Number.isInteger(page) || page < 1) { + throw new Error(`Page must be a whole number of 1 or more, got "${String(value)}".`); + } + return page; + }, + email({ value, label }: { value: unknown; label: string }): string { + const text = typeof value === 'string' ? value.trim() : ''; + if (!EMAIL.test(text)) { + throw new Error(`${label} must be a valid email address, got "${String(value ?? '')}".`); + } + return text; + }, + toDate(value: unknown): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const text = String(value).trim().slice(0, 10); + if (!ISO_DATE.test(text) || Number.isNaN(Date.parse(`${text}T00:00:00Z`))) { + throw new Error(`"${String(value)}" is not a valid date. Use YYYY-MM-DD.`); + } + return text; + }, + toDateTime({ + value, + label, + future = false, + }: { + value: unknown; + label: string; + future?: boolean; + }): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const text = String(value).trim(); + const time = Date.parse(text); + if (Number.isNaN(time)) { + throw new Error(`${label} must be an ISO 8601 date-time, got "${text}".`); + } + if (future && time <= Date.now()) { + throw new Error(`${label} must be in the future, got "${text}".`); + } + return text; + }, + toIdList(value: unknown): number[] | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (typeof value === 'number') { + return kitCommon.toIdList([value]); + } + if (typeof value === 'string') { + const text = value.trim(); + if (text.startsWith('[')) { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`"${text}" is not a valid list of IDs. Use a JSON array like [123, 456] or comma-separated IDs.`); + } + return kitCommon.toIdList(parsed); + } + return kitCommon.toIdList( + text + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ); + } + if (!Array.isArray(value)) { + throw new Error('IDs must be a list of numeric IDs.'); + } + if (value.length === 0) { + return undefined; + } + return value.map((item) => { + const id = Number(item); + if (!Number.isInteger(id)) { + throw new Error(`"${String(item)}" is not a valid numeric ID.`); + } + return id; + }); + }, + toFields(value: unknown): Record | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (typeof value === 'string') { + try { + return kitCommon.toFields(JSON.parse(value)); + } catch { + throw new Error('Custom Fields must be a JSON object of field key to value.'); + } + } + if (typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Custom Fields must be a JSON object of field key to value.'); + } + const fields: Record = Object.fromEntries(Object.entries(value)); + return Object.keys(fields).length > 0 ? fields : undefined; + }, + compact(values: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== null && value !== '') { + result[key] = value; + } + } + return result; + }, +}; diff --git a/packages/pieces/community/convertkit/src/lib/common/custom-api-guard.ts b/packages/pieces/community/convertkit/src/lib/common/custom-api-guard.ts new file mode 100644 index 000000000000..a0d46c77e683 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/common/custom-api-guard.ts @@ -0,0 +1,54 @@ +import { CONVERTKIT_API_URL } from './constants'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const KIT_ORIGIN = new URL(CONVERTKIT_API_URL).origin; +const KIT_PATH = new URL(CONVERTKIT_API_URL).pathname; + +const resolveTarget = (url: string): string => { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + const relative = url.startsWith('/') ? url.slice(1) : url; + return `${CONVERTKIT_API_URL}/${relative}`; +}; + +const hasDotSegment = (url: string): boolean => { + const path = url.split(/[?#]/)[0] ?? ''; + return path + .split('/') + .some((segment) => { + const decoded = segment.replace(/%2e/gi, '.'); + return decoded === '.' || decoded === '..'; + }); +}; + +const isKitTarget = (url: string): boolean => { + if (url.startsWith('//') || url.includes('\\') || hasDotSegment(url)) { + return false; + } + let target: URL; + try { + target = new URL(resolveTarget(url)); + } catch { + return false; + } + if (target.origin !== KIT_ORIGIN || target.username !== '' || target.password !== '') { + return false; + } + return target.pathname === KIT_PATH || target.pathname.startsWith(`${KIT_PATH}/`); +}; + +export const assertKitUrl = (propsValue: unknown): void => { + const urlProp = isRecord(propsValue) ? propsValue['url'] : undefined; + const url = isRecord(urlProp) ? urlProp['url'] : undefined; + if (typeof url !== 'string') { + return; + } + if (!isKitTarget(url.trim())) { + throw new Error( + `Custom API Call only sends your Kit API Secret to ${CONVERTKIT_API_URL}. Use a path relative to it, e.g. /subscribers.` + ); + } +}; diff --git a/packages/pieces/community/convertkit/src/lib/common/custom-fields/index.ts b/packages/pieces/community/convertkit/src/lib/common/custom-fields/index.ts index 7b6e30b12480..d8836e90776c 100644 --- a/packages/pieces/community/convertkit/src/lib/common/custom-fields/index.ts +++ b/packages/pieces/community/convertkit/src/lib/common/custom-fields/index.ts @@ -1,7 +1,7 @@ import { Property, DynamicPropsValue } from '@activepieces/pieces-framework'; import { CustomField } from '../types'; import { fetchCustomFields } from '../service'; -import { convertkitAuth } from '../../..'; +import { convertkitAuth } from '../../auth'; export const fieldsArray = Property.Array({ displayName: 'Fields', diff --git a/packages/pieces/community/convertkit/src/lib/common/forms/index.ts b/packages/pieces/community/convertkit/src/lib/common/forms/index.ts index 0068b23a14e3..6d338370c97a 100644 --- a/packages/pieces/community/convertkit/src/lib/common/forms/index.ts +++ b/packages/pieces/community/convertkit/src/lib/common/forms/index.ts @@ -1,6 +1,6 @@ import { Property } from '@activepieces/pieces-framework'; import { fetchForms } from '../../common/service'; -import { convertkitAuth } from '../../..'; +import { convertkitAuth } from '../../auth'; export const formId = Property.Dropdown({ displayName: 'Form', diff --git a/packages/pieces/community/convertkit/src/lib/common/http.ts b/packages/pieces/community/convertkit/src/lib/common/http.ts new file mode 100644 index 000000000000..07c127898133 --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/common/http.ts @@ -0,0 +1,123 @@ +import { + httpClient, + HttpRequest, + HttpResponse, +} from '@activepieces/pieces-common'; + +export const describeKitError = ({ + status, + body, +}: { + status: number; + body: unknown; +}): string => { + const detail = typeof body === 'string' ? body : JSON.stringify(body); + if (status === 401) { + return `Kit rejected the API Secret (401). Reconnect with the V3 API Secret from Kit Settings > Developer. ${detail}`; + } + if (status === 404) { + return `Kit could not find the requested resource (404). Check the ID. ${detail}`; + } + if (status === 422) { + return `Kit rejected the request (422). ${detail}`; + } + if (status === 429) { + return `Kit rate limit reached (429). Wait a moment and retry. ${detail}`; + } + return `Kit API request failed (${status}). ${detail}`; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const secretsOf = (request: HttpRequest): string[] => { + const values: unknown[] = [ + isRecord(request.body) ? request.body['api_secret'] : undefined, + request.queryParams?.['api_secret'], + ]; + return values.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); +}; + +export const redactKitSecret = ({ + text, + secrets = [], +}: { + text: string; + secrets?: string[]; +}): string => { + let result = text; + for (const secret of secrets) { + result = result.split(secret).join('[redacted]'); + } + return result + .replace(/("api_secret"\s*:\s*")[^"]*(")/g, '$1[redacted]$2') + .replace(/(\\"api_secret\\"\s*:\s*\\")[^\\"]*(\\")/g, '$1[redacted]$2') + .replace(/(api_secret=)[^&\s"]*/g, '$1[redacted]'); +}; + +const responseOf = ( + error: unknown +): { status: number; body: unknown } | undefined => { + if (!isRecord(error) || !isRecord(error['response'])) { + return undefined; + } + const status = error['response']['status']; + if (typeof status !== 'number' || status === 0) { + return undefined; + } + return { status, body: error['response']['body'] }; +}; + +const withSecretInQuery = (request: HttpRequest): HttpRequest => { + if (!isRecord(request.body)) { + return request; + } + const secret = request.body['api_secret']; + if (typeof secret !== 'string') { + return request; + } + const body = Object.fromEntries( + Object.entries(request.body).filter(([key]) => key !== 'api_secret') + ); + return { + ...request, + body, + queryParams: { ...(request.queryParams ?? {}), api_secret: secret }, + }; +}; + +export const kitErrorStatus = (error: unknown): number | undefined => { + if (!isRecord(error)) { + return undefined; + } + const status = error['status']; + return typeof status === 'number' ? status : undefined; +}; + +export const kitHttp = { + async sendRequest( + request: HttpRequest + ): Promise> { + try { + return await httpClient.sendRequest(withSecretInQuery(request)); + } catch (error) { + const secrets = secretsOf(request); + const response = responseOf(error); + if (response) { + throw Object.assign( + new Error(redactKitSecret({ text: describeKitError(response), secrets })), + { status: response.status } + ); + } + throw new Error( + `Kit API request failed: ${ + error instanceof Error + ? redactKitSecret({ text: error.message, secrets }) + : 'unknown error' + }` + ); + } + }, +}; diff --git a/packages/pieces/community/convertkit/src/lib/common/sequences/index.ts b/packages/pieces/community/convertkit/src/lib/common/sequences/index.ts index 6e6219545d41..2352cc6b51cf 100644 --- a/packages/pieces/community/convertkit/src/lib/common/sequences/index.ts +++ b/packages/pieces/community/convertkit/src/lib/common/sequences/index.ts @@ -1,6 +1,6 @@ import { Property } from '@activepieces/pieces-framework'; import { fetchSequences } from '../../common/service'; -import { convertkitAuth } from '../../..'; +import { convertkitAuth } from '../../auth'; export const sequenceId = Property.ShortText({ displayName: 'Sequence ID', diff --git a/packages/pieces/community/convertkit/src/lib/common/service.ts b/packages/pieces/community/convertkit/src/lib/common/service.ts index cad1b7292134..aad39a1912a4 100644 --- a/packages/pieces/community/convertkit/src/lib/common/service.ts +++ b/packages/pieces/community/convertkit/src/lib/common/service.ts @@ -19,11 +19,12 @@ import { WEBHOOKS_API_ENDPOINT, } from './constants'; import { - httpClient, HttpMethod, HttpRequest, QueryParams, } from '@activepieces/pieces-common'; +import { Store } from '@activepieces/pieces-framework'; +import { kitErrorStatus, kitHttp } from './http'; export const buildQueryParams = ( auth: string, @@ -45,7 +46,7 @@ export const fetchBroadcasts = async (auth: string, page: number) => { method: HttpMethod.GET, queryParams: buildQueryParams(auth, { page, sort_order: 'desc' }), }; - const response = await httpClient.sendRequest<{ broadcasts: Broadcast[] }>( + const response = await kitHttp.sendRequest<{ broadcasts: Broadcast[] }>( request ); @@ -74,7 +75,7 @@ export const fetchCustomFields = async ( queryParams: buildQueryParams(auth), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ + const response = await kitHttp.sendRequest<{ custom_fields: CustomField[]; }>(request); @@ -101,7 +102,7 @@ export const fetchForms = async (auth: string) => { queryParams: buildQueryParams(auth), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ forms: Form[] }>(request); + const response = await kitHttp.sendRequest<{ forms: Form[] }>(request); const errorMessage = `Failed to fetch forms. Response code: ${ response.status @@ -126,7 +127,7 @@ export const fetchPurchases = async (auth: string, page: number) => { queryParams: buildQueryParams(auth, { page }), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ purchases: Purchase[] }>( + const response = await kitHttp.sendRequest<{ purchases: Purchase[] }>( request ); @@ -152,7 +153,7 @@ export const fetchSequences = async (auth: string) => { queryParams: buildQueryParams(auth), method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ courses: Sequence[] }>( + const response = await kitHttp.sendRequest<{ courses: Sequence[] }>( request ); @@ -182,7 +183,7 @@ export const fetchSubscriperById = async ( method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ subscriber: Subscriber }>( + const response = await kitHttp.sendRequest<{ subscriber: Subscriber }>( request ); @@ -212,7 +213,7 @@ export const fetchSubscriberByEmail = async ( method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ subscribers: Subscriber[] }>( + const response = await kitHttp.sendRequest<{ subscribers: Subscriber[] }>( request ); @@ -244,7 +245,7 @@ export const fetchSubscribedTags = async ( method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ tags: Tag[] }>(request); + const response = await kitHttp.sendRequest<{ tags: Tag[] }>(request); const errorMessage = `Failed to fetch tags. Response code: ${ response.status @@ -268,7 +269,7 @@ export const fetchTags = async (auth: string) => { method: HttpMethod.GET, }; - const response = await httpClient.sendRequest<{ tags: Tag[] }>(request); + const response = await kitHttp.sendRequest<{ tags: Tag[] }>(request); const errorMessage = `Failed to fetch tags. Response code: ${ response.status @@ -295,7 +296,7 @@ export const createWebhook = async (auth: string, payload: object) => { method: HttpMethod.POST, }; - const response = await httpClient.sendRequest<{ rule: Webhook }>(request); + const response = await kitHttp.sendRequest<{ rule: Webhook }>(request); const errorMessage = `Failed to create webhook. Response code: ${ response.status @@ -322,7 +323,7 @@ export const removeWebhook = async (auth: string, ruleId: number) => { method: HttpMethod.DELETE, }; - const response = await httpClient.sendRequest<{ success: boolean }>(request); + const response = await kitHttp.sendRequest<{ success: boolean }>(request); const errorMessage = `Failed to remove webhook. Response code: ${ response.status @@ -337,3 +338,60 @@ export const removeWebhook = async (auth: string, ruleId: number) => { throw new Error(errorMessage); }; + +type WebhookInformation = { + ruleId: number; +}; + +export const kitWebhookLifecycle = { + async enable({ + apiSecret, + store, + storeKey, + payload, + }: { + apiSecret: string; + store: Store; + storeKey: string; + payload: object; + }): Promise { + const rule = await createWebhook(apiSecret, payload); + try { + await store.put(storeKey, { ruleId: rule.id }); + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + try { + await removeWebhook(apiSecret, rule.id); + } catch (cleanupError) { + throw new Error( + `Saving the Kit webhook rule ${rule.id} failed (${reason}) and removing it also failed (${ + cleanupError instanceof Error ? cleanupError.message : 'unknown error' + }). Delete it in Kit to stop duplicate deliveries.` + ); + } + throw new Error(`Saving the Kit webhook rule failed, so it was removed again: ${reason}`); + } + }, + async disable({ + apiSecret, + store, + storeKey, + }: { + apiSecret: string; + store: Store; + storeKey: string; + }): Promise { + const saved = await store.get(storeKey); + if (saved === null || saved === undefined) { + return; + } + try { + await removeWebhook(apiSecret, saved.ruleId); + } catch (error) { + if (kitErrorStatus(error) !== 404) { + throw error; + } + } + await store.delete(storeKey); + }, +}; diff --git a/packages/pieces/community/convertkit/src/lib/common/tags/index.ts b/packages/pieces/community/convertkit/src/lib/common/tags/index.ts index 574e9a7d65bf..fbb68c3f142b 100644 --- a/packages/pieces/community/convertkit/src/lib/common/tags/index.ts +++ b/packages/pieces/community/convertkit/src/lib/common/tags/index.ts @@ -6,8 +6,8 @@ import { fetchSubscribedTags, fetchTags, } from '../service'; -import { Tag, AuthEmail } from '../types'; -import { convertkitAuth } from '../../..'; +import { Tag } from '../types'; +import { convertkitAuth } from '../../auth'; export const tagId = Property.ShortText({ displayName: 'Tag Id', @@ -106,7 +106,7 @@ export const subscriberState = Property.StaticDropdown({ options: { options: [ { label: 'Active', value: 'active' }, - { label: 'canceled', value: 'canceled' }, + { label: 'Cancelled', value: 'cancelled' }, ], }, }); diff --git a/packages/pieces/community/convertkit/src/lib/common/webhooks/index.ts b/packages/pieces/community/convertkit/src/lib/common/webhooks/index.ts index c5767b9fa85a..738a54efd346 100644 --- a/packages/pieces/community/convertkit/src/lib/common/webhooks/index.ts +++ b/packages/pieces/community/convertkit/src/lib/common/webhooks/index.ts @@ -1,7 +1,7 @@ import { Property, DynamicPropsValue } from '@activepieces/pieces-framework'; import { EventType, EventOption } from '../types'; import { fetchTags, fetchForms, fetchSequences } from '../service'; -import { convertkitAuth } from '../../..'; +import { convertkitAuth } from '../../auth'; export const initiatorValue = Property.ShortText({ displayName: 'Initiator Value URL', diff --git a/packages/pieces/community/convertkit/src/lib/output-schemas.ts b/packages/pieces/community/convertkit/src/lib/output-schemas.ts new file mode 100644 index 000000000000..33783872545d --- /dev/null +++ b/packages/pieces/community/convertkit/src/lib/output-schemas.ts @@ -0,0 +1,447 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const subscriberFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Subscriber ID', format: 'number' }, + { key: 'first_name', label: 'First Name' }, + { key: 'email_address', label: 'Email Address', format: 'email' }, + { key: 'state', label: 'State' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'fields', label: 'Custom Fields', dynamicKey: true }, +]; + +const tagFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Tag ID', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const customFieldFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Custom Field ID', format: 'number' }, + { key: 'key', label: 'Key' }, + { key: 'label', label: 'Label' }, + { key: 'name', label: 'Internal Name' }, +]; + +const subscriptionFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Subscription ID', format: 'number' }, + { key: 'state', label: 'State' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'source', label: 'Source' }, + { key: 'referrer', label: 'Referrer', format: 'url' }, + { key: 'subscribable_id', label: 'Subscribed To ID', format: 'number' }, + { key: 'subscribable_type', label: 'Subscribed To Type' }, + { key: 'subscriber', label: 'Subscriber', children: subscriberFields }, +]; + +const broadcastSummaryFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Broadcast ID', format: 'number' }, + { key: 'subject', label: 'Subject' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const broadcastFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Broadcast ID', format: 'number' }, + { key: 'subject', label: 'Subject' }, + { key: 'description', label: 'Description' }, + { key: 'content', label: 'Content', format: 'html' }, + { key: 'public', label: 'Public', format: 'boolean' }, + { key: 'published_at', label: 'Published At', format: 'datetime' }, + { key: 'send_at', label: 'Send At', format: 'datetime' }, + { key: 'email_address', label: 'From Email', format: 'email' }, + { key: 'email_layout_template', label: 'Email Template' }, + { key: 'thumbnail_url', label: 'Thumbnail URL', format: 'image' }, + { key: 'thumbnail_alt', label: 'Thumbnail Alt Text' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const broadcastStatsFields: OutputSchema['fields'] = [ + { key: 'status', label: 'Status' }, + { key: 'recipients', label: 'Recipients', format: 'number' }, + { key: 'emails_opened', label: 'Emails Opened', format: 'number' }, + { key: 'open_rate', label: 'Open Rate', format: 'number' }, + { key: 'emails_clicked', label: 'Emails Clicked', format: 'number' }, + { key: 'total_clicks', label: 'Total Clicks', format: 'number' }, + { key: 'click_rate', label: 'Click Rate', format: 'number' }, + { key: 'unsubscribes', label: 'Unsubscribes', format: 'number' }, + { key: 'unsubscribe_rate', label: 'Unsubscribe Rate', format: 'number' }, + { key: 'progress', label: 'Progress', format: 'number' }, + { key: 'show_total_clicks', label: 'Show Total Clicks', format: 'boolean' }, + { + key: 'open_tracking_disabled', + label: 'Open Tracking Disabled', + format: 'boolean', + }, + { + key: 'click_tracking_disabled', + label: 'Click Tracking Disabled', + format: 'boolean', + }, +]; + +const webhookRuleFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Webhook ID', format: 'number' }, + { key: 'target_url', label: 'Target URL', format: 'url' }, + { + key: 'event', + label: 'Event', + children: [ + { key: 'name', label: 'Event Name' }, + { key: 'tag_id', label: 'Tag ID', format: 'number' }, + { key: 'initiator_value', label: 'Link URL', format: 'url' }, + ], + }, + { key: 'account_id', label: 'Account ID', format: 'number' }, +]; + +export const kitGetAccountOutputSchema: OutputSchema = { + fields: [ + { key: 'name', label: 'Account Name' }, + { key: 'primary_email_address', label: 'Primary Email', format: 'email' }, + { key: 'plan_type', label: 'Plan Type' }, + ], +}; + +export const kitSubscriberOutputSchema: OutputSchema = { + fields: subscriberFields, +}; + +export const kitListSubscribersOutputSchema: OutputSchema = { + fields: [ + { + key: 'subscribers', + label: 'Subscribers', + labelKey: 'email_address', + listItems: subscriberFields, + }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'total_subscribers', label: 'Total Subscribers', format: 'number' }, + ], +}; + +export const kitSubscriberListOutputSchema: OutputSchema = { + itemLabel: '{email_address}', + fields: [ + { + key: 'subscribers', + label: 'Subscribers', + value: '', + listItems: subscriberFields, + }, + ], +}; + +export const kitTagOutputSchema: OutputSchema = { + fields: tagFields, +}; + +export const kitCreateTagOutputSchema: OutputSchema = { + fields: [ + ...tagFields, + { key: 'created', label: 'Newly Created', format: 'boolean' }, + ], +}; + +export const kitListTagsOutputSchema: OutputSchema = { + fields: [ + { key: 'tags', label: 'Tags', labelKey: 'name', listItems: tagFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const kitTagListOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [{ key: 'tags', label: 'Tags', value: '', listItems: tagFields }], +}; + +export const kitSubscriptionOutputSchema: OutputSchema = { + fields: subscriptionFields, +}; + +export const kitListTagSubscriptionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'subscriptions', + label: 'Subscriptions', + labelKey: 'id', + listItems: subscriptionFields, + }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { + key: 'total_subscriptions', + label: 'Total Subscriptions', + format: 'number', + }, + ], +}; + +export const kitSubscriptionListOutputSchema: OutputSchema = { + itemLabel: 'Subscription {id}', + fields: [ + { + key: 'subscriptions', + label: 'Subscriptions', + value: '', + listItems: subscriptionFields, + }, + ], +}; + +export const kitRemoveTagByEmailOutputSchema: OutputSchema = { + fields: [ + { key: 'removed', label: 'Removed', format: 'boolean' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'tag_id', label: 'Tag ID', format: 'number' }, + { key: 'tag_name', label: 'Tag Name' }, + ], +}; + +export const kitRemoveTagFromSubscriberOutputSchema: OutputSchema = { + fields: [ + { key: 'removed', label: 'Removed', format: 'boolean' }, + { key: 'subscriber_id', label: 'Subscriber ID' }, + { key: 'tag_id', label: 'Tag ID', format: 'number' }, + { key: 'tag_name', label: 'Tag Name' }, + ], +}; + +export const kitCustomFieldOutputSchema: OutputSchema = { + fields: customFieldFields, +}; + +export const kitUpdateCustomFieldOutputSchema: OutputSchema = { + fields: [ + ...customFieldFields, + { key: 'refreshed', label: 'Read Back After Rename', format: 'boolean' }, + { key: 'refresh_error', label: 'Read Back Error' }, + ], +}; + +export const kitListCustomFieldsOutputSchema: OutputSchema = { + fields: [ + { + key: 'custom_fields', + label: 'Custom Fields', + labelKey: 'label', + listItems: customFieldFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const kitCustomFieldListOutputSchema: OutputSchema = { + itemLabel: '{label}', + fields: [ + { + key: 'custom_fields', + label: 'Custom Fields', + value: '', + listItems: customFieldFields, + }, + ], +}; + +export const kitDeleteCustomFieldOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'custom_field_id', label: 'Custom Field ID' }, + ], +}; + +export const kitRequestStatusOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'status', label: 'Status Code', format: 'number' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const kitListBroadcastsOutputSchema: OutputSchema = { + fields: [ + { + key: 'broadcasts', + label: 'Broadcasts', + labelKey: 'subject', + listItems: broadcastSummaryFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'page', label: 'Page', format: 'number' }, + ], +}; + +export const kitBroadcastListOutputSchema: OutputSchema = { + itemLabel: '{subject}', + fields: [ + { + key: 'broadcasts', + label: 'Broadcasts', + value: '', + listItems: broadcastSummaryFields, + }, + ], +}; + +export const kitBroadcastOutputSchema: OutputSchema = { + fields: broadcastFields, +}; + +export const kitGetBroadcastStatsOutputSchema: OutputSchema = { + fields: [ + { key: 'broadcast_id', label: 'Broadcast ID', format: 'number' }, + ...broadcastStatsFields, + ], +}; + +export const kitBroadcastStatsOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Broadcast ID', format: 'number' }, + { key: 'stats', label: 'Stats', children: broadcastStatsFields }, + ], +}; + +export const kitDeleteBroadcastOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'broadcast_id', label: 'Broadcast ID' }, + ], +}; + +export const kitWebhookOutputSchema: OutputSchema = { + fields: webhookRuleFields, +}; + +export const kitListWebhooksOutputSchema: OutputSchema = { + fields: [ + { + key: 'rules', + label: 'Webhooks', + labelKey: 'target_url', + listItems: [...webhookRuleFields, { key: 'status', label: 'Status' }], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const kitDeleteWebhookOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'rule_id', label: 'Webhook ID' }, + ], +}; + +const formFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Form ID', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'format', label: 'Format' }, + { key: 'embed_url', label: 'Embed URL', format: 'url' }, + { key: 'embed_js', label: 'Embed Script URL', format: 'url' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'uid', label: 'UID' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +const sequenceFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Sequence ID', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'available', label: 'Available', format: 'boolean' }, + { key: 'hold', label: 'On Hold', format: 'boolean' }, + { key: 'repeat', label: 'Repeat', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, +]; + +export const kitListFormsOutputSchema: OutputSchema = { + fields: [ + { key: 'forms', label: 'Forms', labelKey: 'name', listItems: formFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const kitListSequencesOutputSchema: OutputSchema = { + fields: [ + { + key: 'sequences', + label: 'Sequences', + labelKey: 'name', + listItems: sequenceFields, + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const kitFormListOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [{ key: 'forms', label: 'Forms', value: '', listItems: formFields }], +}; + +export const kitSequenceListOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [ + { + key: 'sequences', + label: 'Sequences', + value: '', + listItems: sequenceFields, + }, + ], +}; + +const purchaseProductFields: OutputSchema['fields'] = [ + { key: 'pid', label: 'Product ID' }, + { key: 'lid', label: 'Line Item ID' }, + { key: 'name', label: 'Name' }, + { key: 'sku', label: 'SKU' }, + { key: 'unit_price', label: 'Unit Price', format: 'number' }, + { key: 'quantity', label: 'Quantity', format: 'number' }, +]; + +const purchaseFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Purchase ID', format: 'number' }, + { key: 'transaction_id', label: 'Transaction ID' }, + { key: 'status', label: 'Status' }, + { key: 'email_address', label: 'Email Address', format: 'email' }, + { key: 'currency', label: 'Currency' }, + { key: 'transaction_time', label: 'Transaction Time', format: 'datetime' }, + { key: 'subtotal', label: 'Subtotal', format: 'number' }, + { key: 'discount', label: 'Discount', format: 'number' }, + { key: 'tax', label: 'Tax', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { + key: 'products', + label: 'Products', + labelKey: 'name', + listItems: purchaseProductFields, + }, +]; + +export const kitPurchaseOutputSchema: OutputSchema = { + fields: purchaseFields, +}; + +export const kitListPurchasesOutputSchema: OutputSchema = { + fields: [ + { + key: 'purchases', + label: 'Purchases', + labelKey: 'transaction_id', + listItems: purchaseFields, + }, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'total_purchases', label: 'Total Purchases', format: 'number' }, + ], +}; + +export const kitPurchaseListOutputSchema: OutputSchema = { + itemLabel: 'Purchase {transaction_id}', + fields: [ + { + key: 'purchases', + label: 'Purchases', + value: '', + listItems: purchaseFields, + }, + ], +}; diff --git a/packages/pieces/community/convertkit/src/lib/triggers/index.ts b/packages/pieces/community/convertkit/src/lib/triggers/index.ts index 8dbf74f75a18..badf96e21983 100644 --- a/packages/pieces/community/convertkit/src/lib/triggers/index.ts +++ b/packages/pieces/community/convertkit/src/lib/triggers/index.ts @@ -1,15 +1,11 @@ import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; -import { convertkitAuth } from '../..'; +import { convertkitAuth } from '../auth'; import { tag } from '../common/tags'; import { formId } from '../common/forms'; import { sequenceIdDropdown } from '../common/sequences'; import { productId } from '../common/purchases'; import { initiatorValue } from '../common/webhooks'; -import { createWebhook, removeWebhook } from '../common/service'; - -interface WebhookInformation { - ruleId: number; -} +import { kitWebhookLifecycle } from '../common/service'; const sampleData = { rule: { @@ -49,23 +45,19 @@ export const addTag = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_tag_add`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_tag_add', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_tag_add' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_tag_add', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -100,23 +92,19 @@ export const removeTag = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_tag_remove`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_tag_remove', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_tag_remove' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_tag_remove', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -147,23 +135,19 @@ export const subscriberActivated = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_activated`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_activated', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_activated' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_activated', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -193,23 +177,19 @@ export const subscriberUnsubscribed = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_unsubscribed`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_unsubscribed', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_unsubscribed' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_unsubscribed', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -240,23 +220,19 @@ export const subscriberBounced = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_bounced`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_bounced', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_bounced' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_bounced', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -290,23 +266,19 @@ export const subscriberComplained = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_subscriber_complained`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_complained', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_subscriber_complained' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_subscriber_complained', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -341,20 +313,19 @@ export const formSubscribed = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put(`_webhook_form_subscribed`, { - ruleId, + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_form_subscribed', + payload, }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_form_subscribed' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_form_subscribed', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -389,23 +360,19 @@ export const sequenceSubscribed = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_sequence_subscribed`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_sequence_subscribed', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_sequence_subscribed' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_sequence_subscribed', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -440,23 +407,19 @@ export const sequenceCompleted = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put( - `_webhook_sequence_completed`, - { - ruleId, - } - ); + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_sequence_completed', + payload, + }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_sequence_completed' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_sequence_completed', + }); }, async run(context) { const body = context.payload.body as { subscriber: unknown }; @@ -491,20 +454,19 @@ export const linkClicked = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put(`_webhook_link_clicked`, { - ruleId, + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_link_clicked', + payload, }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_link_clicked' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_link_clicked', + }); }, async run(context) { const { initiatorValue } = context.propsValue; @@ -540,20 +502,19 @@ export const productPurchased = createTrigger({ }, target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put(`_webhook_product_purchased`, { - ruleId, + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_product_purchased', + payload, }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_product_purchased' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_product_purchased', + }); }, async run(context) { const { productId } = context.propsValue; @@ -584,20 +545,19 @@ export const purchaseCreated = createTrigger({ target_url: context.webhookUrl, }; - const response = await createWebhook(context.auth.secret_text, payload); - const ruleId = response.id; - - await context.store?.put(`_webhook_purchase_created`, { - ruleId, + await kitWebhookLifecycle.enable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_purchase_created', + payload, }); }, async onDisable(context) { - const response = await context.store?.get( - '_webhook_purchase_created' - ); - if (response !== null && response !== undefined) { - await removeWebhook(context.auth.secret_text, response.ruleId); - } + await kitWebhookLifecycle.disable({ + apiSecret: context.auth.secret_text, + store: context.store, + storeKey: '_webhook_purchase_created', + }); }, async run(context) { const body = context.payload.body as { diff --git a/packages/pieces/community/convertkit/test/actions.test.ts b/packages/pieces/community/convertkit/test/actions.test.ts new file mode 100644 index 000000000000..4e0a17e1f543 --- /dev/null +++ b/packages/pieces/community/convertkit/test/actions.test.ts @@ -0,0 +1,163 @@ +/// + +import { kitCreateTag } from '../src/lib/actions/ai/create-tag'; +import { kitTagSubscriber } from '../src/lib/actions/ai/tag-subscriber'; +import { kitRemoveTagFromSubscriber } from '../src/lib/actions/ai/remove-tag-from-subscriber'; +import { kitUpdateSubscriber } from '../src/lib/actions/ai/update-subscriber'; +import { kitUpdateBroadcast } from '../src/lib/actions/ai/update-broadcast'; +import { kitCreateBroadcast } from '../src/lib/actions/ai/create-broadcast'; +import { kitUpdateCustomField } from '../src/lib/actions/ai/update-custom-field'; +import { kitListSubscribers } from '../src/lib/actions/ai/list-subscribers'; +import { kitCreateWebhook } from '../src/lib/actions/ai/create-webhook'; +import { SECRET, call, mockKit, run } from './helpers'; + +afterEach(() => vi.restoreAllMocks()); + +describe('Create Tag (get-or-create)', () => { + test('returns the existing tag with created:false on a case-insensitive name match, without creating', async () => { + const { requests } = mockKit([{ body: { tags: [{ id: 9, name: 'VIP Customers', created_at: '2026-01-01' }] } }]); + const result = await run({ action: kitCreateTag, propsValue: { name: ' vip customers ' } }); + expect(result).toEqual({ id: 9, name: 'VIP Customers', created_at: '2026-01-01', created: false }); + expect(requests).toHaveLength(1); + expect(requests[0].method).toBe('GET'); + }); + + test('creates the tag when none matches, with the secret in the query and not the body', async () => { + const { requests } = mockKit([{ body: { tags: [] } }, { status: 201, body: { id: 10, name: 'New', created_at: '2026-01-02' } }]); + const result = await run({ action: kitCreateTag, propsValue: { name: 'New' } }); + expect(result).toEqual({ id: 10, name: 'New', created_at: '2026-01-02', created: true }); + expect(requests[1]).toMatchObject({ method: 'POST', url: 'https://api.convertkit.com/v3/tags', body: { tag: { name: 'New' } } }); + expect(requests[1].queryParams).toEqual({ api_secret: SECRET }); + }); +}); + +describe('Tag Subscriber', () => { + test('sends the exact subscribe request', async () => { + const { requests } = mockKit([{ body: { subscription: { id: 1 } } }]); + await run({ action: kitTagSubscriber, propsValue: { tag_id: '123', email: ' a@b.co ', additional_tag_ids: ['4', 5], first_name: '' } }); + expect(requests[0]).toMatchObject({ + method: 'POST', + url: 'https://api.convertkit.com/v3/tags/123/subscribe', + body: { email: 'a@b.co', tags: [4, 5] }, + }); + expect(requests[0].body).not.toHaveProperty('first_name'); + }); + + test('refuses a non-numeric tag ID before any request', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitTagSubscriber, propsValue: { tag_id: '12/../34', email: 'a@b.co' } })).rejects.toThrow('Tag ID must be a numeric Kit ID'); + expect(requests).toHaveLength(0); + }); +}); + +describe('Remove Tag From Subscriber', () => { + test('uses DELETE /subscribers/:id/tags/:tag_id', async () => { + const { requests } = mockKit([{ body: { id: 2, name: 'VIP' } }]); + const result = await run({ action: kitRemoveTagFromSubscriber, propsValue: { subscriber_id: '1', tag_id: '2' } }); + expect(requests[0]).toMatchObject({ method: 'DELETE', url: 'https://api.convertkit.com/v3/subscribers/1/tags/2' }); + expect(result).toEqual({ removed: true, subscriber_id: '1', tag_id: 2, tag_name: 'VIP' }); + }); +}); + +describe('Update Subscriber (partial update)', () => { + test('an empty first name is left out so the stored name is kept', async () => { + const { requests } = mockKit([{ body: { subscriber: { id: 1 } } }]); + await run({ action: kitUpdateSubscriber, propsValue: { subscriber_id: '1', first_name: '', fields: { plan: 'pro' } } }); + expect(requests[0].body).toEqual({ fields: { plan: 'pro' } }); + }); + + test('an update with nothing set is refused before any request', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitUpdateSubscriber, propsValue: { subscriber_id: '1', first_name: '', email_address: '' } })).rejects.toThrow('Provide at least one'); + expect(requests).toHaveLength(0); + }); +}); + +describe('Update Broadcast (partial update)', () => { + test('sends only the fields that were set and never send_at implicitly', async () => { + const { requests } = mockKit([{ body: { broadcast: { id: 5 } } }]); + await run({ action: kitUpdateBroadcast, propsValue: { broadcast_id: '5', subject: 'New subject', public: 'false' } }); + expect(requests[0]).toMatchObject({ method: 'PUT', url: 'https://api.convertkit.com/v3/broadcasts/5' }); + expect(requests[0].body).toEqual({ subject: 'New subject', public: false }); + }); + + test('a past send time is refused before any request', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitUpdateBroadcast, propsValue: { broadcast_id: '5', send_at: '2000-01-01T00:00:00Z' } })).rejects.toThrow('Send At must be in the future'); + expect(requests).toHaveLength(0); + }); + + test('Create Broadcast stays a draft when send_at is empty', async () => { + const { requests } = mockKit([{ status: 201, body: { broadcast: { id: 6 } } }]); + await run({ action: kitCreateBroadcast, propsValue: { subject: 'Draft', content: '

x

' } }); + expect(requests[0].body).toEqual({ subject: 'Draft', content: '

x

' }); + }); +}); + +describe('Update Custom Field', () => { + test('a failed read-back after a successful rename is reported, not thrown', async () => { + const { requests } = mockKit([{ status: 204 }, { error: { status: 500, body: { error: 'boom' } } }]); + const result = await run({ action: kitUpdateCustomField, propsValue: { custom_field_id: '77', label: 'Plan' } }); + expect(requests[0]).toMatchObject({ method: 'PUT', url: 'https://api.convertkit.com/v3/custom_fields/77', body: { label: 'Plan' } }); + expect(result).toMatchObject({ id: 77, label: 'Plan', refreshed: false }); + expect(JSON.stringify(result)).not.toContain(SECRET); + }); + + test('returns the refreshed field when the read-back works', async () => { + mockKit([{ status: 204 }, { body: { custom_fields: [{ id: 77, key: 'plan', label: 'Plan', name: 'ck_field_77_plan' }] } }]); + const result = await run({ action: kitUpdateCustomField, propsValue: { custom_field_id: '77', label: 'Plan' } }); + expect(result).toEqual({ id: 77, key: 'plan', label: 'Plan', name: 'ck_field_77_plan', refreshed: true }); + }); +}); + +describe('List Subscribers', () => { + test('passes date filters as YYYY-MM-DD and a validated page', async () => { + const { requests } = mockKit([{ body: { subscribers: [], page: 2, total_pages: 2, total_subscribers: 51 } }]); + const result = await run({ action: kitListSubscribers, propsValue: { from: '2026-09-01T12:00:00Z', page: 2 } }); + expect(requests[0].queryParams).toEqual({ api_secret: SECRET, page: '2', from: '2026-09-01' }); + expect(result).toEqual({ subscribers: [], page: 2, total_pages: 2, total_subscribers: 51 }); + }); + + test('an invalid date is refused before any request', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitListSubscribers, propsValue: { from: 'last week' } })).rejects.toThrow('is not a valid date'); + expect(requests).toHaveLength(0); + }); +}); + +describe('Create Webhook', () => { + test('refuses a non-https target before any request', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitCreateWebhook, propsValue: { target_url: 'http://example.com/hook', event: 'subscriber.subscriber_activate' } })).rejects.toThrow('Target URL must be a public https:// URL'); + expect(requests).toHaveLength(0); + }); + + test('refuses an event whose required parameter is missing', async () => { + const { requests } = mockKit([]); + await expect(run({ action: kitCreateWebhook, propsValue: { target_url: 'https://example.com/hook', event: 'subscriber.tag_add' } })).rejects.toThrow('requires'); + expect(requests).toHaveLength(0); + }); +}); + +describe('connection validation', () => { + test('a 401 from Kit is reported as a rejected API Secret', async () => { + const { convertkitAuth } = await import('../src/lib/auth'); + const { requests } = mockKit([{ error: { status: 401, body: { error: 'Authorization Failed' } } }]); + const result = await call({ target: convertkitAuth, method: 'validate', context: { auth: SECRET } }); + expect(requests[0]).toMatchObject({ method: 'GET', url: 'https://api.convertkit.com/v3/account', queryParams: { api_secret: SECRET } }); + expect(result).toEqual({ valid: false, error: expect.stringContaining('Kit rejected this API Secret') }); + }); + + test('validates the secret exactly as actions will send it', async () => { + const { convertkitAuth } = await import('../src/lib/auth'); + const { requests } = mockKit([{ body: { name: 'Odai', primary_email_address: 'a@b.co' } }]); + await call({ target: convertkitAuth, method: 'validate', context: { auth: ` ${SECRET} ` } }); + expect(requests[0]).toMatchObject({ queryParams: { api_secret: ` ${SECRET} ` } }); + }); + + test('a working secret is valid', async () => { + const { convertkitAuth } = await import('../src/lib/auth'); + mockKit([{ body: { name: 'Odai', primary_email_address: 'a@b.co' } }]); + expect(await call({ target: convertkitAuth, method: 'validate', context: { auth: SECRET } })).toEqual({ valid: true }); + }); +}); diff --git a/packages/pieces/community/convertkit/test/client.test.ts b/packages/pieces/community/convertkit/test/client.test.ts new file mode 100644 index 000000000000..2aeb198e49b6 --- /dev/null +++ b/packages/pieces/community/convertkit/test/client.test.ts @@ -0,0 +1,57 @@ +/// + +import { kitCommon } from '../src/lib/common/client'; + +describe('kitCommon.toIdList', () => { + test.each([ + [['1', 2], [1, 2]], + ['[3, 4]', [3, 4]], + ['5, 6', [5, 6]], + [7, [7]], + ])('parses %j', (input, expected) => { + expect(kitCommon.toIdList(input)).toEqual(expected); + }); + + test('treats empty input as unset', () => { + expect(kitCommon.toIdList(undefined)).toBeUndefined(); + expect(kitCommon.toIdList('')).toBeUndefined(); + expect(kitCommon.toIdList([])).toBeUndefined(); + }); + + test('refuses a non-numeric ID instead of dropping it', () => { + expect(() => kitCommon.toIdList(['abc'])).toThrow('not a valid numeric ID'); + expect(() => kitCommon.toIdList('[1,')).toThrow('not a valid list of IDs'); + }); +}); + +describe('input validation', () => { + test('id accepts numeric strings and numbers only', () => { + expect(kitCommon.id({ value: ' 42 ', label: 'Tag ID' })).toBe('42'); + expect(kitCommon.id({ value: 42, label: 'Tag ID' })).toBe('42'); + expect(() => kitCommon.id({ value: '42/../x', label: 'Tag ID' })).toThrow('Tag ID must be a numeric Kit ID'); + expect(() => kitCommon.id({ value: '', label: 'Tag ID' })).toThrow('Tag ID must be a numeric Kit ID'); + }); + + test('page defaults to 1 and refuses 0 or fractions', () => { + expect(kitCommon.page(undefined)).toBe(1); + expect(kitCommon.page('3')).toBe(3); + expect(() => kitCommon.page(0)).toThrow('Page must be a whole number'); + expect(() => kitCommon.page(1.5)).toThrow('Page must be a whole number'); + }); + + test('email refuses a malformed address', () => { + expect(kitCommon.email({ value: ' a@b.co ', label: 'Email' })).toBe('a@b.co'); + expect(() => kitCommon.email({ value: 'not-an-email', label: 'Email' })).toThrow('Email must be a valid email address'); + }); + + test('toDate keeps YYYY-MM-DD and refuses anything else', () => { + expect(kitCommon.toDate('2026-09-01T10:00:00Z')).toBe('2026-09-01'); + expect(() => kitCommon.toDate('yesterday')).toThrow('is not a valid date'); + }); + + test('toDateTime refuses a past time when a future one is required', () => { + expect(() => kitCommon.toDateTime({ value: '2000-01-01T00:00:00Z', label: 'Send At', future: true })).toThrow('Send At must be in the future'); + expect(() => kitCommon.toDateTime({ value: 'soon', label: 'Send At' })).toThrow('Send At must be an ISO 8601 date-time'); + expect(kitCommon.toDateTime({ value: '2999-01-01T00:00:00Z', label: 'Send At', future: true })).toBe('2999-01-01T00:00:00Z'); + }); +}); diff --git a/packages/pieces/community/convertkit/test/custom-api-guard.test.ts b/packages/pieces/community/convertkit/test/custom-api-guard.test.ts new file mode 100644 index 000000000000..7b5d4481a9f8 --- /dev/null +++ b/packages/pieces/community/convertkit/test/custom-api-guard.test.ts @@ -0,0 +1,33 @@ +/// + +import { assertKitUrl } from '../src/lib/common/custom-api-guard'; + +const check = (url: string) => () => assertKitUrl({ url: { url } }); + +describe('Custom API Call host guard', () => { + test.each([ + '/account', + 'account', + '/subscribers?page=2', + 'https://api.convertkit.com/v3/account', + 'https://api.convertkit.com/v3', + ])('allows %s', (url) => { + expect(check(url)).not.toThrow(); + }); + + test.each([ + 'https://example.com/steal', + 'https://api.convertkit.com.evil.io/v3/account', + 'https://api.convertkit.com@evil.io/v3/account', + 'https://user:pass@api.convertkit.com/v3/account', + 'http://api.convertkit.com/v3/account', + 'https://api.convertkit.com/v4/account', + 'https://api.convertkit.com/v3/../v4/account', + '/../account', + '%2e%2e/account', + '//evil.io/account', + '\\\\evil.io\\account', + ])('refuses %s', (url) => { + expect(check(url)).toThrow('only sends your Kit API Secret'); + }); +}); diff --git a/packages/pieces/community/convertkit/test/helpers.ts b/packages/pieces/community/convertkit/test/helpers.ts new file mode 100644 index 000000000000..f217006fb2d7 --- /dev/null +++ b/packages/pieces/community/convertkit/test/helpers.ts @@ -0,0 +1,40 @@ +import { HttpRequest, HttpResponse, httpClient } from '@activepieces/pieces-common'; + +export const SECRET = 'sk_test_fixture_secret_000000000000'; +export const AUTH = { secret_text: SECRET }; + +export type Reply = { status?: number; body?: unknown } | { error: { status: number; body: unknown } }; + +export function mockKit(replies: Reply[]) { + const requests: HttpRequest[] = []; + const spy = vi.spyOn(httpClient, 'sendRequest').mockImplementation(async (request: HttpRequest): Promise => { + requests.push(request); + const reply = replies.shift() ?? { status: 200, body: {} }; + if ('error' in reply) { + throw Object.assign(new Error(`Request failed with status ${reply.error.status}: ${JSON.stringify(request)}`), { + response: { status: reply.error.status, body: reply.error.body }, + }); + } + return { status: reply.status ?? 200, headers: {}, body: reply.body ?? {} }; + }); + return { requests, spy }; +} + +export function call({ target, method, context }: { target: object; method: string; context: unknown }): Promise { + const fn: unknown = Reflect.get(target, method); + if (typeof fn !== 'function') { + throw new Error(`${method} is not a function`); + } + return Promise.resolve(Reflect.apply(fn, target, [context])); +} + +export function run({ action, propsValue }: { action: object; propsValue: Record }) { + return call({ target: action, method: 'run', context: { auth: AUTH, propsValue } }); +} + +export function messageOf(error: unknown): string { + if (!(error instanceof Error)) { + throw new Error('expected an Error'); + } + return error.message; +} diff --git a/packages/pieces/community/convertkit/test/http.test.ts b/packages/pieces/community/convertkit/test/http.test.ts new file mode 100644 index 000000000000..0560aa67c866 --- /dev/null +++ b/packages/pieces/community/convertkit/test/http.test.ts @@ -0,0 +1,72 @@ +/// + +import { HttpMethod } from '@activepieces/pieces-common'; +import { kitHttp, kitErrorStatus, redactKitSecret, describeKitError } from '../src/lib/common/http'; +import { SECRET, messageOf, mockKit } from './helpers'; + +afterEach(() => vi.restoreAllMocks()); + +describe('kitHttp', () => { + test('moves api_secret from the body to the query so the logged request body never carries it', async () => { + const { requests } = mockKit([{ status: 201, body: { id: 1 } }]); + await kitHttp.sendRequest({ + method: HttpMethod.POST, + url: 'https://api.convertkit.com/v3/tags', + body: { api_secret: SECRET, tag: { name: 'x' } }, + }); + expect(requests[0].body).toEqual({ tag: { name: 'x' } }); + expect(requests[0].queryParams).toEqual({ api_secret: SECRET }); + expect(JSON.stringify(requests[0].body)).not.toContain(SECRET); + }); + + test('keeps a GET request unchanged', async () => { + const { requests } = mockKit([{ status: 200, body: { tags: [] } }]); + await kitHttp.sendRequest({ + method: HttpMethod.GET, + url: 'https://api.convertkit.com/v3/tags', + queryParams: { api_secret: SECRET }, + }); + expect(requests[0].queryParams).toEqual({ api_secret: SECRET }); + expect(requests[0].body).toBeUndefined(); + }); + + test('a Kit HTTP error rethrows with the status and never the secret', async () => { + mockKit([{ error: { status: 404, body: { error: 'Not Found', echo: `api_secret=${SECRET}` } } }]); + const error = await kitHttp + .sendRequest({ method: HttpMethod.PUT, url: 'https://api.convertkit.com/v3/custom_fields/1', body: { api_secret: SECRET, label: 'x' } }) + .catch((e: unknown) => e); + expect(messageOf(error)).toContain('Kit could not find the requested resource (404)'); + expect(messageOf(error)).not.toContain(SECRET); + expect(kitErrorStatus(error)).toBe(404); + }); + + test('a network error message is redacted', async () => { + vi.spyOn((await import('@activepieces/pieces-common')).httpClient, 'sendRequest').mockRejectedValue( + new Error(`socket hang up ${JSON.stringify({ api_secret: SECRET })}`) + ); + const error = await kitHttp + .sendRequest({ method: HttpMethod.POST, url: 'https://api.convertkit.com/v3/tags', body: { api_secret: SECRET } }) + .catch((e: unknown) => e); + expect(messageOf(error)).toContain('Kit API request failed'); + expect(messageOf(error)).not.toContain(SECRET); + expect(kitErrorStatus(error)).toBeUndefined(); + }); +}); + +describe('redactKitSecret', () => { + test.each([ + [`{"api_secret":"${SECRET}"}`], + [`{\\"api_secret\\":\\"${SECRET}\\"}`], + [`https://api.convertkit.com/v3/tags?api_secret=${SECRET}&page=1`], + ])('hides the secret in %s', (text) => { + expect(redactKitSecret({ text })).not.toContain(SECRET); + }); + + test('hides a known secret value wherever it appears', () => { + expect(redactKitSecret({ text: `raw ${SECRET} here`, secrets: [SECRET] })).toBe('raw [redacted] here'); + }); + + test('describes a 401 as a credential problem', () => { + expect(describeKitError({ status: 401, body: {} })).toContain('Kit rejected the API Secret (401)'); + }); +}); diff --git a/packages/pieces/community/convertkit/test/triggers.test.ts b/packages/pieces/community/convertkit/test/triggers.test.ts new file mode 100644 index 000000000000..2917296c8c03 --- /dev/null +++ b/packages/pieces/community/convertkit/test/triggers.test.ts @@ -0,0 +1,78 @@ +/// + +import { subscriberActivated } from '../src/lib/triggers'; +import { AUTH, call, mockKit } from './helpers'; + +afterEach(() => vi.restoreAllMocks()); + +function memoryStore({ failPut = false }: { failPut?: boolean } = {}) { + const data = new Map(); + return { + data, + put: vi.fn(async (key: string, value: unknown) => { + if (failPut) { + throw new Error('store unavailable'); + } + data.set(key, value); + return value; + }), + get: vi.fn(async (key: string) => data.get(key) ?? null), + delete: vi.fn(async (key: string) => { + data.delete(key); + }), + }; +} + +const KEY = '_webhook_subscriber_activated'; +const lifecycle = ({ method, store }: { method: 'onEnable' | 'onDisable'; store: ReturnType }) => + call({ + target: subscriberActivated, + method, + context: { auth: AUTH, propsValue: {}, webhookUrl: 'https://cloud.example/webhooks/1', store }, + }); + +describe('webhook trigger lifecycle', () => { + test('onEnable creates the Kit rule and stores its ID', async () => { + const { requests } = mockKit([{ body: { rule: { id: 55 } } }]); + const store = memoryStore(); + await lifecycle({ method: 'onEnable', store }); + expect(requests[0]).toMatchObject({ + method: 'POST', + url: 'https://api.convertkit.com/v3/automations/hooks', + body: { event: { name: 'subscriber.subscriber_activate' }, target_url: 'https://cloud.example/webhooks/1' }, + }); + expect(store.data.get(KEY)).toEqual({ ruleId: 55 }); + }); + + test('onEnable deletes the rule it created when saving its ID fails', async () => { + const { requests } = mockKit([{ body: { rule: { id: 56 } } }, { body: { success: true } }]); + const store = memoryStore({ failPut: true }); + await expect(lifecycle({ method: 'onEnable', store })).rejects.toThrow('so it was removed again'); + expect(requests[1]).toMatchObject({ method: 'DELETE', url: 'https://api.convertkit.com/v3/automations/hooks/56' }); + }); + + test('onDisable deletes the rule and then forgets its ID', async () => { + const { requests } = mockKit([{ body: { success: true } }]); + const store = memoryStore(); + store.data.set(KEY, { ruleId: 57 }); + await lifecycle({ method: 'onDisable', store }); + expect(requests[0]).toMatchObject({ method: 'DELETE', url: 'https://api.convertkit.com/v3/automations/hooks/57' }); + expect(store.data.has(KEY)).toBe(false); + }); + + test('onDisable treats a 404 as already deleted', async () => { + mockKit([{ error: { status: 404, body: { error: 'Not Found' } } }]); + const store = memoryStore(); + store.data.set(KEY, { ruleId: 58 }); + await lifecycle({ method: 'onDisable', store }); + expect(store.data.has(KEY)).toBe(false); + }); + + test('onDisable keeps the ID when Kit fails, so a retry can still delete the rule', async () => { + mockKit([{ error: { status: 500, body: { error: 'boom' } } }]); + const store = memoryStore(); + store.data.set(KEY, { ruleId: 59 }); + await expect(lifecycle({ method: 'onDisable', store })).rejects.toThrow('Kit API request failed (500)'); + expect(store.data.get(KEY)).toEqual({ ruleId: 59 }); + }); +}); diff --git a/packages/pieces/community/convertkit/vitest.config.ts b/packages/pieces/community/convertkit/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/convertkit/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + 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'), + }, + }, +}) diff --git a/packages/pieces/community/customer-io/package.json b/packages/pieces/community/customer-io/package.json index f8d306f8c456..946a9e87b479 100644 --- a/packages/pieces/community/customer-io/package.json +++ b/packages/pieces/community/customer-io/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-customer-io", - "version": "0.4.1", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { @@ -11,11 +11,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/customer-io/src/i18n/translation.json b/packages/pieces/community/customer-io/src/i18n/translation.json index 290fc0278a6f..ad17b12f73f2 100644 --- a/packages/pieces/community/customer-io/src/i18n/translation.json +++ b/packages/pieces/community/customer-io/src/i18n/translation.json @@ -21,15 +21,19 @@ "Headers": "Headers", "Query Parameters": "Query Parameters", "Body Type": "Body Type", - "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", "The unique value representing a person. You may identify a person by id, email address, or the cio_id (when updating people), depending on your workspace settings": "The unique value representing a person. You may identify a person by id, email address, or the cio_id (when updating people), depending on your workspace settings", "The name of the event. This is how you'll reference the event in campaigns or segments": "The name of the event. This is how you'll reference the event in campaigns or segments", "Event information to be sent to Customer.io": "Event information to be sent to Customer.io", "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.", "Key Value": "Key Value", "JSON": "JSON", "GET": "GET", @@ -40,5 +44,85 @@ "HEAD": "HEAD", "None": "None", "Form Data": "Form Data", - "Raw": "Raw" + "Raw": "Raw", + "New Reporting Event": "New Reporting Event", + "Fires on a Customer.io reporting-webhook event (delivered, bounced, clicked, …). Subscribe to only the event types you need, and optionally match a single campaign, action, or recipient domain so unrelated events never start a run.": "Fires on a Customer.io reporting-webhook event (delivered, bounced, clicked, …). Subscribe to only the event types you need, and optionally match a single campaign, action, or recipient domain so unrelated events never start a run.", + "Event Types": "Event Types", + "Campaign ID": "Campaign ID", + "Action ID": "Action ID", + "Recipient Domain": "Recipient Domain", + "Which reporting events Customer.io should send.": "Which reporting events Customer.io should send.", + "Optional. Only start a run for events of this campaign.": "Optional. Only start a run for events of this campaign.", + "Optional. When set, only events for this action/message start a run.": "Optional. When set, only events for this action/message start a run.", + "Optional. Only start a run when the recipient email is at this domain.": "Optional. Only start a run when the recipient email is at this domain.", + "Customer — Subscribed": "Customer — Subscribed", + "Customer — Unsubscribed": "Customer — Unsubscribed", + "Customer — Subscription Preferences Changed": "Customer — Subscription Preferences Changed", + "Email — Drafted": "Email — Drafted", + "Email — Attempted": "Email — Attempted", + "Email — Sent": "Email — Sent", + "Email — Delivered": "Email — Delivered", + "Email — Opened": "Email — Opened", + "Email — Clicked": "Email — Clicked", + "Email — Converted": "Email — Converted", + "Email — Unsubscribed": "Email — Unsubscribed", + "Email — Bounced": "Email — Bounced", + "Email — Dropped": "Email — Dropped", + "Email — Spammed": "Email — Spammed", + "Email — Failed": "Email — Failed", + "Email — Undeliverable": "Email — Undeliverable", + "Push — Drafted": "Push — Drafted", + "Push — Attempted": "Push — Attempted", + "Push — Sent": "Push — Sent", + "Push — Delivered": "Push — Delivered", + "Push — Opened": "Push — Opened", + "Push — Clicked": "Push — Clicked", + "Push — Converted": "Push — Converted", + "Push — Bounced": "Push — Bounced", + "Push — Dropped": "Push — Dropped", + "Push — Failed": "Push — Failed", + "Push — Undeliverable": "Push — Undeliverable", + "SMS — Drafted": "SMS — Drafted", + "SMS — Attempted": "SMS — Attempted", + "SMS — Sent": "SMS — Sent", + "SMS — Delivered": "SMS — Delivered", + "SMS — Clicked": "SMS — Clicked", + "SMS — Converted": "SMS — Converted", + "SMS — Bounced": "SMS — Bounced", + "SMS — Dropped": "SMS — Dropped", + "SMS — Failed": "SMS — Failed", + "SMS — Undeliverable": "SMS — Undeliverable", + "SMS — Replied": "SMS — Replied", + "WhatsApp — Drafted": "WhatsApp — Drafted", + "WhatsApp — Attempted": "WhatsApp — Attempted", + "WhatsApp — Sent": "WhatsApp — Sent", + "WhatsApp — Delivered": "WhatsApp — Delivered", + "WhatsApp — Opened": "WhatsApp — Opened", + "WhatsApp — Clicked": "WhatsApp — Clicked", + "WhatsApp — Converted": "WhatsApp — Converted", + "WhatsApp — Bounced": "WhatsApp — Bounced", + "WhatsApp — Dropped": "WhatsApp — Dropped", + "WhatsApp — Failed": "WhatsApp — Failed", + "WhatsApp — Undeliverable": "WhatsApp — Undeliverable", + "WhatsApp — Replied": "WhatsApp — Replied", + "Slack — Drafted": "Slack — Drafted", + "Slack — Attempted": "Slack — Attempted", + "Slack — Sent": "Slack — Sent", + "Slack — Clicked": "Slack — Clicked", + "Slack — Failed": "Slack — Failed", + "Slack — Undeliverable": "Slack — Undeliverable", + "Webhook — Drafted": "Webhook — Drafted", + "Webhook — Attempted": "Webhook — Attempted", + "Webhook — Sent": "Webhook — Sent", + "Webhook — Clicked": "Webhook — Clicked", + "Webhook — Failed": "Webhook — Failed", + "Webhook — Undeliverable": "Webhook — Undeliverable", + "In-App — Drafted": "In-App — Drafted", + "In-App — Attempted": "In-App — Attempted", + "In-App — Sent": "In-App — Sent", + "In-App — Opened": "In-App — Opened", + "In-App — Clicked": "In-App — Clicked", + "In-App — Converted": "In-App — Converted", + "In-App — Failed": "In-App — Failed", + "In-App — Undeliverable": "In-App — Undeliverable" } \ No newline at end of file diff --git a/packages/pieces/community/customer-io/src/index.ts b/packages/pieces/community/customer-io/src/index.ts index 1bf82e5780dd..7d7c8907c5c3 100644 --- a/packages/pieces/community/customer-io/src/index.ts +++ b/packages/pieces/community/customer-io/src/index.ts @@ -4,6 +4,7 @@ import { Property, } from '@activepieces/pieces-framework'; import { createEvent } from './lib/actions/create_event'; +import { newReportingEvent } from './lib/trigger/new-reporting-event'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { customerIOCommon } from './lib/common'; import { Buffer } from 'buffer'; @@ -71,5 +72,5 @@ export const customerIo: any = createPiece({ }), }), ], - triggers: [], + triggers: [newReportingEvent], }); diff --git a/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.test.ts b/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.test.ts new file mode 100644 index 000000000000..42e94c25e1ec --- /dev/null +++ b/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.test.ts @@ -0,0 +1,133 @@ +/// + +import { newReportingEvent } from './new-reporting-event'; + +const EVENT = { + event_id: '01E4C8AY5K21N2QNRBD9YXJ13Z', + object_type: 'email', + metric: 'delivered', + timestamp: 1585254331, + data: { + action_id: 489, + campaign_id: 20, + recipient: 'user@odoo.com', + }, +}; + +const buildContext = ({ + propsValue, + body, +}: { + propsValue: unknown; + body: unknown; +}) => + ({ + propsValue, + payload: { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + queryParams: {}, + }, + } as never); + +describe('customer-io new-reporting-event run()', () => { + test('returns the event when no filters are set', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'] }, body: EVENT }) + ); + + expect(output).toEqual([EVENT]); + }); + + test('returns the event when campaign, action and domain all match', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { + events: ['email_delivered'], + campaign_id: 20, + action_id: 489, + recipient_domain: 'odoo.com', + }, body: EVENT }) + ); + + expect(output).toEqual([EVENT]); + }); + + test('drops the event when the campaign does not match', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], campaign_id: 99 }, body: EVENT }) + ); + + expect(output).toEqual([]); + }); + + test('drops the event when the action does not match', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], action_id: 1 }, body: EVENT }) + ); + + expect(output).toEqual([]); + }); + + test('drops the event when the recipient domain does not match', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], recipient_domain: 'example.com' }, body: EVENT }) + ); + + expect(output).toEqual([]); + }); + + test('matches the recipient domain case-insensitively and tolerates a leading @', async () => { + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], recipient_domain: '@ODOO.com' }, body: EVENT }) + ); + + expect(output).toEqual([EVENT]); + }); + + test('matches on email_address when recipient is absent', async () => { + const event = { + ...EVENT, + data: { action_id: 489, campaign_id: 20, email_address: 'user@odoo.com' }, + }; + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], recipient_domain: 'odoo.com' }, body: event }) + ); + + expect(output).toEqual([event]); + }); + + test('matches on identifiers.email even when recipient holds a non-email address', async () => { + const event = { + ...EVENT, + object_type: 'sms', + data: { + action_id: 489, + campaign_id: 20, + recipient: '+15551234567', + identifiers: { email: 'user@odoo.com' }, + }, + }; + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['sms_delivered'], recipient_domain: 'odoo.com' }, body: event }) + ); + + expect(output).toEqual([event]); + }); + + test('matches on identifiers.email when recipient and email_address are absent', async () => { + const event = { + ...EVENT, + data: { + action_id: 489, + campaign_id: 20, + identifiers: { email: 'user@odoo.com' }, + }, + }; + const output = await newReportingEvent.run( + buildContext({ propsValue: { events: ['email_delivered'], recipient_domain: 'odoo.com' }, body: event }) + ); + + expect(output).toEqual([event]); + }); +}); diff --git a/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.ts b/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.ts new file mode 100644 index 000000000000..24ee67ada9f7 --- /dev/null +++ b/packages/pieces/community/customer-io/src/lib/trigger/new-reporting-event.ts @@ -0,0 +1,278 @@ +import { HttpError, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { + Property, + TriggerStrategy, + createTrigger, +} from '@activepieces/pieces-framework'; +import { customerIOAuth } from '../auth'; +import { customerIOCommon } from '../common'; + +const WEBHOOK_ID_STORE_KEY = 'customerio_reporting_webhook_id'; + +const CUSTOMER_IO_EVENTS: { label: string; value: string }[] = [ + { label: 'Customer — Subscribed', value: 'customer_subscribed' }, + { label: 'Customer — Unsubscribed', value: 'customer_unsubscribed' }, + { + label: 'Customer — Subscription Preferences Changed', + value: 'customer_subscription_preferences_changed', + }, + { label: 'Email — Drafted', value: 'email_drafted' }, + { label: 'Email — Attempted', value: 'email_attempted' }, + { label: 'Email — Sent', value: 'email_sent' }, + { label: 'Email — Delivered', value: 'email_delivered' }, + { label: 'Email — Opened', value: 'email_opened' }, + { label: 'Email — Clicked', value: 'email_clicked' }, + { label: 'Email — Converted', value: 'email_converted' }, + { label: 'Email — Unsubscribed', value: 'email_unsubscribed' }, + { label: 'Email — Bounced', value: 'email_bounced' }, + { label: 'Email — Dropped', value: 'email_dropped' }, + { label: 'Email — Spammed', value: 'email_spammed' }, + { label: 'Email — Failed', value: 'email_failed' }, + { label: 'Email — Undeliverable', value: 'email_undeliverable' }, + { label: 'Push — Drafted', value: 'push_drafted' }, + { label: 'Push — Attempted', value: 'push_attempted' }, + { label: 'Push — Sent', value: 'push_sent' }, + { label: 'Push — Delivered', value: 'push_delivered' }, + { label: 'Push — Opened', value: 'push_opened' }, + { label: 'Push — Clicked', value: 'push_clicked' }, + { label: 'Push — Converted', value: 'push_converted' }, + { label: 'Push — Bounced', value: 'push_bounced' }, + { label: 'Push — Dropped', value: 'push_dropped' }, + { label: 'Push — Failed', value: 'push_failed' }, + { label: 'Push — Undeliverable', value: 'push_undeliverable' }, + { label: 'SMS — Drafted', value: 'sms_drafted' }, + { label: 'SMS — Attempted', value: 'sms_attempted' }, + { label: 'SMS — Sent', value: 'sms_sent' }, + { label: 'SMS — Delivered', value: 'sms_delivered' }, + { label: 'SMS — Clicked', value: 'sms_clicked' }, + { label: 'SMS — Converted', value: 'sms_converted' }, + { label: 'SMS — Bounced', value: 'sms_bounced' }, + { label: 'SMS — Dropped', value: 'sms_dropped' }, + { label: 'SMS — Failed', value: 'sms_failed' }, + { label: 'SMS — Undeliverable', value: 'sms_undeliverable' }, + { label: 'SMS — Replied', value: 'sms_replied' }, + { label: 'WhatsApp — Drafted', value: 'whatsapp_drafted' }, + { label: 'WhatsApp — Attempted', value: 'whatsapp_attempted' }, + { label: 'WhatsApp — Sent', value: 'whatsapp_sent' }, + { label: 'WhatsApp — Delivered', value: 'whatsapp_delivered' }, + { label: 'WhatsApp — Opened', value: 'whatsapp_opened' }, + { label: 'WhatsApp — Clicked', value: 'whatsapp_clicked' }, + { label: 'WhatsApp — Converted', value: 'whatsapp_converted' }, + { label: 'WhatsApp — Bounced', value: 'whatsapp_bounced' }, + { label: 'WhatsApp — Dropped', value: 'whatsapp_dropped' }, + { label: 'WhatsApp — Failed', value: 'whatsapp_failed' }, + { label: 'WhatsApp — Undeliverable', value: 'whatsapp_undeliverable' }, + { label: 'WhatsApp — Replied', value: 'whatsapp_replied' }, + { label: 'Slack — Drafted', value: 'slack_drafted' }, + { label: 'Slack — Attempted', value: 'slack_attempted' }, + { label: 'Slack — Sent', value: 'slack_sent' }, + { label: 'Slack — Clicked', value: 'slack_clicked' }, + { label: 'Slack — Failed', value: 'slack_failed' }, + { label: 'Slack — Undeliverable', value: 'slack_undeliverable' }, + { label: 'Webhook — Drafted', value: 'webhook_drafted' }, + { label: 'Webhook — Attempted', value: 'webhook_attempted' }, + { label: 'Webhook — Sent', value: 'webhook_sent' }, + { label: 'Webhook — Clicked', value: 'webhook_clicked' }, + { label: 'Webhook — Failed', value: 'webhook_failed' }, + { label: 'Webhook — Undeliverable', value: 'webhook_undeliverable' }, + { label: 'In-App — Drafted', value: 'in_app_drafted' }, + { label: 'In-App — Attempted', value: 'in_app_attempted' }, + { label: 'In-App — Sent', value: 'in_app_sent' }, + { label: 'In-App — Opened', value: 'in_app_opened' }, + { label: 'In-App — Clicked', value: 'in_app_clicked' }, + { label: 'In-App — Converted', value: 'in_app_converted' }, + { label: 'In-App — Failed', value: 'in_app_failed' }, + { label: 'In-App — Undeliverable', value: 'in_app_undeliverable' }, +]; + +export const newReportingEvent = createTrigger({ + auth: customerIOAuth, + name: 'new_reporting_event', + classification: 'READ', + displayName: 'New Reporting Event', + description: + 'Fires on a Customer.io reporting-webhook event (delivered, bounced, clicked, …). Subscribe to only the event types you need, and optionally match a single campaign, action, or recipient domain so unrelated events never start a run.', + aiMetadata: { + description: + 'Fires on each Customer.io reporting-webhook event of the subscribed types (e.g. email_delivered, email_bounced, sms_clicked). Requires the event types to subscribe to, and optionally filters by campaign ID, action ID, or recipient email domain so only matching events start a run. Returns the raw Customer.io event envelope (event_id, object_type, metric, timestamp, data).', + }, + type: TriggerStrategy.WEBHOOK, + props: { + events: Property.StaticMultiSelectDropdown({ + displayName: 'Event Types', + description: 'Which reporting events Customer.io should send.', + required: true, + options: { + options: CUSTOMER_IO_EVENTS, + }, + }), + campaign_id: Property.Number({ + displayName: 'Campaign ID', + description: 'Optional. Only start a run for events of this campaign.', + required: false, + }), + action_id: Property.Number({ + displayName: 'Action ID', + description: + 'Optional. When set, only events for this action/message start a run.', + required: false, + }), + recipient_domain: Property.ShortText({ + displayName: 'Recipient Domain', + description: + 'Optional. Only start a run when the recipient email is at this domain.', + placeholder: 'odoo.com', + required: false, + }), + }, + sampleData: { + event_id: '01E4C8AY5K21N2QNRBD9YXJ13Z', + object_type: 'email', + metric: 'delivered', + timestamp: 1585254331, + data: { + action_id: 489, + campaign_id: 20, + customer_id: '0200102', + identifiers: { + id: '0200102', + email: 'user@odoo.com', + cio_id: 'd9c106000001', + }, + delivery_id: 'RPILAgABcRhIBqSp7kiPekGBIeVh', + recipient: 'user@odoo.com', + subject: 'Welcome to Alan', + }, + }, + async onEnable(context) { + const { region, api_bearer_token } = context.auth.props; + const apiUrl = customerIOCommon[region || 'us'].apiUrl; + const segments = context.webhookUrl.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments[segments.length - 1]; + const flowIdentifier = + lastSegment === 'test' ? segments[segments.length - 2] : lastSegment; + const response = await httpClient.sendRequest<{ id: number }>({ + method: HttpMethod.POST, + url: `${apiUrl}reporting_webhooks`, + headers: { Authorization: `Bearer ${api_bearer_token}` }, + body: { + endpoint: context.webhookUrl, + events: context.propsValue.events, + name: flowIdentifier ? `Activepieces (${flowIdentifier})` : 'Activepieces', + disabled: false, + }, + }); + try { + await context.store.put(WEBHOOK_ID_STORE_KEY, response.body.id); + } catch (error) { + await deleteReportingWebhook({ + apiUrl, + token: api_bearer_token, + webhookId: response.body.id, + }).catch(() => undefined); + throw error; + } + }, + async onDisable(context) { + const webhookId = await context.store.get(WEBHOOK_ID_STORE_KEY); + if (!webhookId) { + return; + } + const { region, api_bearer_token } = context.auth.props; + await deleteReportingWebhook({ + apiUrl: customerIOCommon[region || 'us'].apiUrl, + token: api_bearer_token, + webhookId, + }); + }, + async run(context) { + const body = context.payload.body; + const event: CustomerIoReportingEvent = isReportingEvent(body) ? body : {}; + const { campaign_id, action_id, recipient_domain } = context.propsValue; + const data = event.data ?? {}; + + if ( + campaign_id !== undefined && + campaign_id !== null && + data.campaign_id !== campaign_id + ) { + return []; + } + if ( + action_id !== undefined && + action_id !== null && + data.action_id !== action_id + ) { + return []; + } + if ( + recipient_domain && + ![data.recipient, data.email_address, data.identifiers?.email].some( + (candidate) => + recipientMatchesDomain({ + recipient: candidate, + domain: recipient_domain, + }) + ) + ) { + return []; + } + return [event]; + }, +}); + +async function deleteReportingWebhook({ + apiUrl, + token, + webhookId, +}: { + apiUrl: string; + token: string; + webhookId: number; +}): Promise { + try { + await httpClient.sendRequest({ + method: HttpMethod.DELETE, + url: `${apiUrl}reporting_webhooks/${webhookId}`, + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (error) { + if (error instanceof HttpError && error.response.status === 404) { + return; + } + throw error; + } +} + +function isReportingEvent(body: unknown): body is CustomerIoReportingEvent { + return typeof body === 'object' && body !== null; +} + +function recipientMatchesDomain({ + recipient, + domain, +}: { + recipient: string | undefined; + domain: string; +}): boolean { + if (!recipient) { + return false; + } + const normalized = domain.replace(/^@/, '').toLowerCase(); + return recipient.toLowerCase().endsWith(`@${normalized}`); +} + +type CustomerIoReportingEvent = { + event_id?: string; + object_type?: string; + metric?: string; + timestamp?: number; + data?: { + campaign_id?: number; + action_id?: number; + recipient?: string; + email_address?: string; + identifiers?: { email?: string }; + [key: string]: unknown; + }; +}; diff --git a/packages/pieces/community/customer-io/vitest.config.ts b/packages/pieces/community/customer-io/vitest.config.ts new file mode 100644 index 000000000000..1bd3d1e5fe77 --- /dev/null +++ b/packages/pieces/community/customer-io/vitest.config.ts @@ -0,0 +1,19 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + 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'), + '@activepieces/core-piece-types': path.resolve(repoRoot, 'packages/core/piece-types/src/index.ts'), + '@activepieces/core-utils': path.resolve(repoRoot, 'packages/core/utils/src/index.ts'), + }, + }, +}) diff --git a/packages/pieces/community/elevenlabs/package.json b/packages/pieces/community/elevenlabs/package.json index 43cc89892b1d..a966d03da89c 100644 --- a/packages/pieces/community/elevenlabs/package.json +++ b/packages/pieces/community/elevenlabs/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-elevenlabs", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "@elevenlabs/elevenlabs-js": "2.4.1", + "form-data": "4.0.6", "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*" }, diff --git a/packages/pieces/community/elevenlabs/src/index.ts b/packages/pieces/community/elevenlabs/src/index.ts index 5b6bd0e4f290..e760bfb6a1e9 100644 --- a/packages/pieces/community/elevenlabs/src/index.ts +++ b/packages/pieces/community/elevenlabs/src/index.ts @@ -1,80 +1,199 @@ -import { createPiece, PieceAuth, Property } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { AppConnectionType, PieceCategory } from '@activepieces/pieces-framework'; +import { PieceCategory } from '@activepieces/pieces-framework'; +import { elevenlabsAuth } from './lib/auth'; import { textToSpeech } from './lib/actions/text-to-speech-action'; -import { - createClient, - ELEVEN_RESIDENCY, - ElevenResidency, - getApiKey, - getRegionApiUrl -} from './lib/common'; - -const markdownDescription = ` -Follow these instructions to get your API Key: -1. Visit your Elevenlabs dashboard. -2. Once there, click on your account in the bottom left corner. -3. Press Profile + API Key. -4. Create or copy your API Key. Make sure to enable the following permissions: **user:read**, **text_to_speech**, **voices:read**, and **models**. -`; +import { addPronunciationRules } from './lib/actions/ai/add-pronunciation-rules'; +import { calculateAgentLlmUsage } from './lib/actions/ai/calculate-agent-llm-usage'; +import { calculateLlmUsage } from './lib/actions/ai/calculate-llm-usage'; +import { convertSpeechToSpeech } from './lib/actions/ai/convert-speech-to-speech'; +import { createAgentTest } from './lib/actions/ai/create-agent-test'; +import { createAgent } from './lib/actions/ai/create-agent'; +import { createAudioNativeProject } from './lib/actions/ai/create-audio-native-project'; +import { createDubbing } from './lib/actions/ai/create-dubbing'; +import { createKbFileDocument } from './lib/actions/ai/create-kb-file-document'; +import { createKbFolder } from './lib/actions/ai/create-kb-folder'; +import { createKbRagIndex } from './lib/actions/ai/create-kb-rag-index'; +import { createKbTextDocument } from './lib/actions/ai/create-kb-text-document'; +import { createKbUrlDocument } from './lib/actions/ai/create-kb-url-document'; +import { createPronunciationDictionaryFromFile } from './lib/actions/ai/create-pronunciation-dictionary-from-file'; +import { createPronunciationDictionaryFromRules } from './lib/actions/ai/create-pronunciation-dictionary-from-rules'; +import { createSecret } from './lib/actions/ai/create-secret'; +import { createTool } from './lib/actions/ai/create-tool'; +import { deleteDubbing } from './lib/actions/ai/delete-dubbing'; +import { deleteKbDocument } from './lib/actions/ai/delete-kb-document'; +import { deleteKbRagIndex } from './lib/actions/ai/delete-kb-rag-index'; +import { deleteSecret } from './lib/actions/ai/delete-secret'; +import { deleteTool } from './lib/actions/ai/delete-tool'; +import { downloadHistoryItems } from './lib/actions/ai/download-history-items'; +import { duplicateAgent } from './lib/actions/ai/duplicate-agent'; +import { findSimilarVoices } from './lib/actions/ai/find-similar-voices'; +import { getAgentKnowledgeBaseSize } from './lib/actions/ai/get-agent-knowledge-base-size'; +import { getAgentLink } from './lib/actions/ai/get-agent-link'; +import { getAgentTestSummaries } from './lib/actions/ai/get-agent-test-summaries'; +import { getAgentTest } from './lib/actions/ai/get-agent-test'; +import { getAgentWidget } from './lib/actions/ai/get-agent-widget'; +import { getAgent } from './lib/actions/ai/get-agent'; +import { getAudioNativeSettings } from './lib/actions/ai/get-audio-native-settings'; +import { getConversationSignedUrl } from './lib/actions/ai/get-conversation-signed-url'; +import { getDefaultVoiceSettings } from './lib/actions/ai/get-default-voice-settings'; +import { getDubbedFile } from './lib/actions/ai/get-dubbed-file'; +import { getDubbingTranscriptFormatted } from './lib/actions/ai/get-dubbing-transcript-formatted'; +import { getDubbingTranscript } from './lib/actions/ai/get-dubbing-transcript'; +import { getDubbing } from './lib/actions/ai/get-dubbing'; +import { getHistoryItemAudio } from './lib/actions/ai/get-history-item-audio'; +import { getHistoryItem } from './lib/actions/ai/get-history-item'; +import { getKbDocumentContent } from './lib/actions/ai/get-kb-document-content'; +import { getKbDocumentRagIndexes } from './lib/actions/ai/get-kb-document-rag-indexes'; +import { getKbDocumentSourceUrl } from './lib/actions/ai/get-kb-document-source-url'; +import { getKbDocument } from './lib/actions/ai/get-kb-document'; +import { getKbRagIndexOverview } from './lib/actions/ai/get-kb-rag-index-overview'; +import { getKbSummaries } from './lib/actions/ai/get-kb-summaries'; +import { getLiveConversationCount } from './lib/actions/ai/get-live-conversation-count'; +import { getPronunciationDictionaryVersion } from './lib/actions/ai/get-pronunciation-dictionary-version'; +import { getPronunciationDictionary } from './lib/actions/ai/get-pronunciation-dictionary'; +import { getSubscription } from './lib/actions/ai/get-subscription'; +import { getTestInvocation } from './lib/actions/ai/get-test-invocation'; +import { getTool } from './lib/actions/ai/get-tool'; +import { getUser } from './lib/actions/ai/get-user'; +import { getVoiceSettings } from './lib/actions/ai/get-voice-settings'; +import { getVoice } from './lib/actions/ai/get-voice'; +import { isolateAudio } from './lib/actions/ai/isolate-audio'; +import { listAgentTests } from './lib/actions/ai/list-agent-tests'; +import { listAgents } from './lib/actions/ai/list-agents'; +import { listBatchCalls } from './lib/actions/ai/list-batch-calls'; +import { listConversations } from './lib/actions/ai/list-conversations'; +import { listDubbings } from './lib/actions/ai/list-dubbings'; +import { listHistoryItems } from './lib/actions/ai/list-history-items'; +import { listKbDependentAgents } from './lib/actions/ai/list-kb-dependent-agents'; +import { listKbDocuments } from './lib/actions/ai/list-kb-documents'; +import { listModels } from './lib/actions/ai/list-models'; +import { listPhoneNumbers } from './lib/actions/ai/list-phone-numbers'; +import { listPronunciationDictionaries } from './lib/actions/ai/list-pronunciation-dictionaries'; +import { listSecrets } from './lib/actions/ai/list-secrets'; +import { listSharedVoices } from './lib/actions/ai/list-shared-voices'; +import { listTestInvocations } from './lib/actions/ai/list-test-invocations'; +import { listToolDependentAgents } from './lib/actions/ai/list-tool-dependent-agents'; +import { listTools } from './lib/actions/ai/list-tools'; +import { listVoices } from './lib/actions/ai/list-voices'; +import { moveKbDocument } from './lib/actions/ai/move-kb-document'; +import { moveKbDocumentsBulk } from './lib/actions/ai/move-kb-documents-bulk'; +import { removePronunciationRules } from './lib/actions/ai/remove-pronunciation-rules'; +import { resubmitTestInvocation } from './lib/actions/ai/resubmit-test-invocation'; +import { runAgentTests } from './lib/actions/ai/run-agent-tests'; +import { setAgentAvatar } from './lib/actions/ai/set-agent-avatar'; +import { simulateAgentConversation } from './lib/actions/ai/simulate-agent-conversation'; +import { updateAgentTest } from './lib/actions/ai/update-agent-test'; +import { updateAgent } from './lib/actions/ai/update-agent'; +import { updateAudioNativeContent } from './lib/actions/ai/update-audio-native-content'; +import { updateKbDocument } from './lib/actions/ai/update-kb-document'; +import { updatePronunciationDictionary } from './lib/actions/ai/update-pronunciation-dictionary'; +import { updateSecret } from './lib/actions/ai/update-secret'; +import { updateTool } from './lib/actions/ai/update-tool'; +import { getApiKey, getRegionApiUrl } from './lib/common'; const customApiCallDescription = ` Check [Elevenlabs API reference](https://elevenlabs.io/docs/api-reference/introduction) for the list of available endpoints. ` -export const elevenlabsAuth = PieceAuth.CustomAuth({ - required: true, - description: markdownDescription, - props: { - region: Property.StaticDropdown({ - displayName: 'Region', - description: 'Use according URL in Custom API Call pieces', - required: true, - options: { - placeholder: 'Please select your account region...', - options: [ - { label: `default - ${ELEVEN_RESIDENCY['default'].base}`, value: 'default' }, - { label: `US - ${ELEVEN_RESIDENCY['us'].base}`, value: 'us' }, - { label: `EU - ${ELEVEN_RESIDENCY['eu'].base}`, value: 'eu' }, - ], - }, - }), - apiKey: PieceAuth.SecretText({ - displayName: 'API Key', - required: true, - }), - }, - validate: async ({ auth }) => { - try { - const elevenlabs = createClient({ - type: AppConnectionType.CUSTOM_AUTH, - props: auth, - }); - await elevenlabs.user.get(); - - return { - valid: true, - }; - } catch (error) { - return { - valid: false, - error: 'Invalid API Key or Region.', - }; - } - }, -}); - export const elevenlabs = createPiece({ displayName: 'ElevenLabs', auth: elevenlabsAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/elevenlabs.png', authors: ['pfernandez98'], categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE], description: 'AI Voice Generator & Text to Speech', actions: [ textToSpeech, + addPronunciationRules, + calculateAgentLlmUsage, + calculateLlmUsage, + convertSpeechToSpeech, + createAgentTest, + createAgent, + createAudioNativeProject, + createDubbing, + createKbFileDocument, + createKbFolder, + createKbRagIndex, + createKbTextDocument, + createKbUrlDocument, + createPronunciationDictionaryFromFile, + createPronunciationDictionaryFromRules, + createSecret, + createTool, + deleteDubbing, + deleteKbDocument, + deleteKbRagIndex, + deleteSecret, + deleteTool, + downloadHistoryItems, + duplicateAgent, + findSimilarVoices, + getAgentKnowledgeBaseSize, + getAgentLink, + getAgentTestSummaries, + getAgentTest, + getAgentWidget, + getAgent, + getAudioNativeSettings, + getConversationSignedUrl, + getDefaultVoiceSettings, + getDubbedFile, + getDubbingTranscriptFormatted, + getDubbingTranscript, + getDubbing, + getHistoryItemAudio, + getHistoryItem, + getKbDocumentContent, + getKbDocumentRagIndexes, + getKbDocumentSourceUrl, + getKbDocument, + getKbRagIndexOverview, + getKbSummaries, + getLiveConversationCount, + getPronunciationDictionaryVersion, + getPronunciationDictionary, + getSubscription, + getTestInvocation, + getTool, + getUser, + getVoiceSettings, + getVoice, + isolateAudio, + listAgentTests, + listAgents, + listBatchCalls, + listConversations, + listDubbings, + listHistoryItems, + listKbDependentAgents, + listKbDocuments, + listModels, + listPhoneNumbers, + listPronunciationDictionaries, + listSecrets, + listSharedVoices, + listTestInvocations, + listToolDependentAgents, + listTools, + listVoices, + moveKbDocument, + moveKbDocumentsBulk, + removePronunciationRules, + resubmitTestInvocation, + runAgentTests, + setAgentAvatar, + simulateAgentConversation, + updateAgentTest, + updateAgent, + updateAudioNativeContent, + updateKbDocument, + updatePronunciationDictionary, + updateSecret, + updateTool, createCustomApiCallAction({ // it would be more useful to have hint for URL description: customApiCallDescription, @@ -94,3 +213,5 @@ export const elevenlabs = createPiece({ ], triggers: [], }); + +export { elevenlabsAuth }; diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/add-pronunciation-rules.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/add-pronunciation-rules.ts new file mode 100644 index 000000000000..0802445944a9 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/add-pronunciation-rules.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsAddPronunciationRulesOutputSchema } from '../../output-schemas'; + +export const addPronunciationRules = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_add_pronunciation_rules', + outputSchema: elevenlabsAddPronunciationRulesOutputSchema, + displayName: 'Add Pronunciation Rules', + description: 'Add rules to a pronunciation dictionary', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Adds alias or phoneme rules to a pronunciation dictionary and returns the new version_id. Each call creates a new dictionary version, so it is not idempotent.', + idempotent: false, + }, + props: { + pronunciationDictionaryId: Property.ShortText({ displayName: 'Pronunciation Dictionary ID', description: 'The id from Create Pronunciation Dictionary or List Pronunciation Dictionaries', required: true }), + rules: Property.Json({ displayName: 'Rules', description: 'JSON array of rules, such as [{"type": "alias", "string_to_replace": "AP", "alias": "Activepieces"}] or {"type": "phoneme", "string_to_replace": "...", "phoneme": "...", "alphabet": "ipa"}', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/pronunciation-dictionaries/${encodeURIComponent(propsValue.pronunciationDictionaryId)}/add-rules`, + body: elevenlabsClient.compact({ values: { rules: propsValue.rules } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-agent-llm-usage.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-agent-llm-usage.ts new file mode 100644 index 000000000000..df9713e66fa5 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-agent-llm-usage.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCalculateAgentLlmUsageOutputSchema } from '../../output-schemas'; + +export const calculateAgentLlmUsage = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_calculate_agent_llm_usage', + outputSchema: elevenlabsCalculateAgentLlmUsageOutputSchema, + displayName: 'Calculate Agent LLM Usage', + description: 'Estimate the LLM cost of an agent', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Estimates the expected LLM price per minute for an agent across the available LLMs. Read-only despite using POST.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + promptLength: Property.Number({ displayName: 'Prompt Length', description: 'Prompt length in characters, defaults to the agent prompt', required: false }), + numberOfPages: Property.Number({ displayName: 'Number Of Pages', description: 'Knowledge base pages, defaults to the agent knowledge base', required: false }), + ragEnabled: Property.StaticDropdown({ displayName: 'Rag Enabled', description: 'Whether RAG is enabled', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agent/${encodeURIComponent(propsValue.agentId)}/llm-usage/calculate`, + body: elevenlabsClient.compact({ values: { prompt_length: propsValue.promptLength, number_of_pages: propsValue.numberOfPages, rag_enabled: propsValue.ragEnabled } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-llm-usage.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-llm-usage.ts new file mode 100644 index 000000000000..c7a011cc71a1 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/calculate-llm-usage.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCalculateAgentLlmUsageOutputSchema } from '../../output-schemas'; + +export const calculateLlmUsage = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_calculate_llm_usage', + outputSchema: elevenlabsCalculateAgentLlmUsageOutputSchema, + displayName: 'Calculate LLM Usage', + description: 'Estimate the LLM cost for a prompt and knowledge base size', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Estimates the expected LLM price per minute for a prompt length and knowledge base size across the available LLMs. Read-only despite using POST.', + idempotent: true, + }, + props: { + promptLength: Property.Number({ displayName: 'Prompt Length', description: 'Prompt length in characters', required: true }), + numberOfPages: Property.Number({ displayName: 'Number Of Pages', description: 'Knowledge base pages', required: true }), + ragEnabled: Property.StaticDropdown({ displayName: 'Rag Enabled', description: 'Whether RAG is enabled', required: true, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/llm-usage/calculate`, + body: elevenlabsClient.compact({ values: { prompt_length: propsValue.promptLength, number_of_pages: propsValue.numberOfPages, rag_enabled: propsValue.ragEnabled } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/convert-speech-to-speech.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/convert-speech-to-speech.ts new file mode 100644 index 000000000000..9d8370971f2e --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/convert-speech-to-speech.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const convertSpeechToSpeech = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_convert_speech_to_speech', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Convert Speech To Speech', + description: 'Re-speak an audio recording in a different voice', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Converts an uploaded audio recording into speech in the chosen voice, keeping the original delivery, and returns the audio as a file. Use to change who is speaking in an existing recording. Not idempotent: each call consumes credits and produces new audio.', + idempotent: false, + }, + props: { + voiceId: Property.ShortText({ displayName: 'Voice ID', description: 'The voice_id from List Voices', required: true }), + audio: Property.File({ displayName: 'Audio', description: 'Recording to convert', required: true }), + modelId: Property.ShortText({ displayName: 'Model ID', description: 'Speech-to-speech model id from List Models, such as eleven_multilingual_sts_v2', required: false }), + removeBackgroundNoise: Property.StaticDropdown({ displayName: 'Remove Background Noise', description: 'Remove background noise from the input', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + outputFormat: Property.StaticDropdown({ displayName: 'Output Format', description: 'Defaults to mp3_44100_128', required: false, options: { options: [{ label: 'mp3_44100_128', value: 'mp3_44100_128' }, { label: 'mp3_44100_192', value: 'mp3_44100_192' }, { label: 'mp3_22050_32', value: 'mp3_22050_32' }, { label: 'pcm_16000', value: 'pcm_16000' }, { label: 'pcm_44100', value: 'pcm_44100' }] } }), + }, + async run({ auth, propsValue, files }) { + const formData = new FormData(); + formData.append('audio', propsValue.audio.data, propsValue.audio.filename); + if (propsValue.modelId !== undefined) { + formData.append('model_id', String(propsValue.modelId)); + } + if (propsValue.removeBackgroundNoise !== undefined) { + formData.append('remove_background_noise', String(propsValue.removeBackgroundNoise)); + } + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.POST, + path: `/v1/speech-to-speech/${encodeURIComponent(propsValue.voiceId)}`, + queryParams: { output_format: propsValue.outputFormat }, + formData, + }); + const file = await files.write({ fileName: `speech.${elevenlabsClient.extensionFor({ contentType: audio.contentType })}`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent-test.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent-test.ts new file mode 100644 index 000000000000..c15468b3f593 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent-test.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateAgentTestOutputSchema } from '../../output-schemas'; + +export const createAgentTest = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_agent_test', + outputSchema: elevenlabsCreateAgentTestOutputSchema, + displayName: 'Create Agent Test', + description: 'Create a response test for an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a test that checks how an agent responds: an llm test judges the reply against a success condition, a tool test checks tool calls, a simulation test runs a full conversation. Returns the test id, which Run Agent Tests takes. Not idempotent: each call creates another test.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Test name', required: true }), + type: Property.StaticDropdown({ displayName: 'Type', description: 'Defaults to llm', required: false, options: { options: [{ label: 'llm', value: 'llm' }, { label: 'tool', value: 'tool' }, { label: 'simulation', value: 'simulation' }] } }), + successCondition: Property.LongText({ displayName: 'Success Condition', description: 'What a passing response must do', required: false }), + chatHistory: Property.Json({ displayName: 'Chat History', description: 'Conversation so far, as a JSON array of transcript turns', required: false }), + successExamples: Property.Json({ displayName: 'Success Examples', description: 'JSON array such as [{"response": "...", "type": "success"}]', required: false }), + failureExamples: Property.Json({ displayName: 'Failure Examples', description: 'JSON array such as [{"response": "...", "type": "failure"}]', required: false }), + dynamicVariables: Property.Json({ displayName: 'Dynamic Variables', description: 'Dynamic variables as a JSON object', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agent-testing/create`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, type: propsValue.type, success_condition: propsValue.successCondition, chat_history: propsValue.chatHistory, success_examples: propsValue.successExamples, failure_examples: propsValue.failureExamples, dynamic_variables: propsValue.dynamicVariables } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent.ts new file mode 100644 index 000000000000..55354e5ee407 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-agent.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateAgentOutputSchema } from '../../output-schemas'; + +export const createAgent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_agent', + outputSchema: elevenlabsCreateAgentOutputSchema, + displayName: 'Create Agent', + description: 'Create a conversational AI agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a conversational AI agent from a conversation_config JSON (prompt, first message, voice, language) and returns its agent_id. Start from the config of an existing agent via Get Agent. Not idempotent: each call creates another agent.', + idempotent: false, + }, + props: { + conversationConfig: Property.Json({ displayName: 'Conversation Config', description: 'Agent configuration, such as {"agent": {"prompt": {"prompt": "You are..."}, "first_message": "Hi"}}', required: true }), + platformSettings: Property.Json({ displayName: 'Platform Settings', description: 'Optional platform settings', required: false }), + name: Property.ShortText({ displayName: 'Name', description: 'Agent name', required: false }), + tags: Property.Array({ displayName: 'Tags', description: 'Tags for the agent', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agents/create`, + body: elevenlabsClient.compact({ values: { conversation_config: propsValue.conversationConfig, platform_settings: propsValue.platformSettings, name: propsValue.name, tags: propsValue.tags } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-audio-native-project.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-audio-native-project.ts new file mode 100644 index 000000000000..3dc224a44f63 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-audio-native-project.ts @@ -0,0 +1,70 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateAudioNativeProjectOutputSchema } from '../../output-schemas'; + +export const createAudioNativeProject = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_audio_native_project', + outputSchema: elevenlabsCreateAudioNativeProjectOutputSchema, + displayName: 'Create Audio Native Project', + description: 'Create an embeddable Audio Native player project', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates an Audio Native project (an embeddable audio player for an article) from an uploaded text or HTML file and returns the project_id and embed html_snippet. Set auto_convert to generate the audio right away. Not idempotent: each call creates a new project.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Project name', required: true }), + title: Property.ShortText({ displayName: 'Title', description: 'Title shown in the player', required: false }), + author: Property.ShortText({ displayName: 'Author', description: 'Author shown in the player', required: false }), + file: Property.File({ displayName: 'File', description: 'Article as a .txt or .html file', required: false }), + voiceId: Property.ShortText({ displayName: 'Voice ID', description: 'voice_id from List Voices', required: false }), + modelId: Property.ShortText({ displayName: 'Model ID', description: 'Model id from List Models', required: false }), + textColor: Property.ShortText({ displayName: 'Text Color', description: 'Player text colour, such as #000000', required: false }), + backgroundColor: Property.ShortText({ displayName: 'Background Color', description: 'Player background colour, such as #FFFFFF', required: false }), + small: Property.StaticDropdown({ displayName: 'Small', description: 'Use the compact player', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + autoConvert: Property.StaticDropdown({ displayName: 'Auto Convert', description: 'Generate the audio right after creation', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('name', String(propsValue.name)); + if (propsValue.title !== undefined) { + formData.append('title', String(propsValue.title)); + } + if (propsValue.author !== undefined) { + formData.append('author', String(propsValue.author)); + } + if (propsValue.file) { + formData.append('file', propsValue.file.data, propsValue.file.filename); + } + if (propsValue.voiceId !== undefined) { + formData.append('voice_id', String(propsValue.voiceId)); + } + if (propsValue.modelId !== undefined) { + formData.append('model_id', String(propsValue.modelId)); + } + if (propsValue.textColor !== undefined) { + formData.append('text_color', String(propsValue.textColor)); + } + if (propsValue.backgroundColor !== undefined) { + formData.append('background_color', String(propsValue.backgroundColor)); + } + if (propsValue.small !== undefined) { + formData.append('small', String(propsValue.small)); + } + if (propsValue.autoConvert !== undefined) { + formData.append('auto_convert', String(propsValue.autoConvert)); + } + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/audio-native`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-dubbing.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-dubbing.ts new file mode 100644 index 000000000000..7066b57bb6bc --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-dubbing.ts @@ -0,0 +1,66 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateDubbingOutputSchema } from '../../output-schemas'; + +export const createDubbing = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_dubbing', + outputSchema: elevenlabsCreateDubbingOutputSchema, + displayName: 'Create Dubbing', + description: 'Dub a video or audio file into another language', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Starts a dubbing job that translates and re-voices a media file or a source URL into the target language, and returns a dubbing_id immediately. Poll Get Dubbing until status is dubbed, then fetch the result with Get Dubbed File. Not idempotent: each call starts a new billable job.', + idempotent: false, + }, + props: { + file: Property.File({ displayName: 'File', description: 'Media file to dub. Provide this or a Source URL', required: false }), + sourceUrl: Property.ShortText({ displayName: 'Source Url', description: 'URL of the media to dub, such as a YouTube link', required: false }), + name: Property.ShortText({ displayName: 'Name', description: 'Name of the dubbing project', required: false }), + targetLang: Property.ShortText({ displayName: 'Target Lang', description: 'Language code to dub into, such as es', required: true }), + sourceLang: Property.ShortText({ displayName: 'Source Lang', description: 'Language code of the original, or auto', required: false }), + numSpeakers: Property.Number({ displayName: 'Num Speakers', description: 'Number of speakers, 0 detects automatically', required: false }), + watermark: Property.StaticDropdown({ displayName: 'Watermark', description: 'Add a watermark', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + dropBackgroundAudio: Property.StaticDropdown({ displayName: 'Drop Background Audio', description: 'Remove the background audio', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + highestResolution: Property.StaticDropdown({ displayName: 'Highest Resolution', description: 'Use the highest video resolution', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + if (propsValue.file) { + formData.append('file', propsValue.file.data, propsValue.file.filename); + } + if (propsValue.sourceUrl !== undefined) { + formData.append('source_url', String(propsValue.sourceUrl)); + } + if (propsValue.name !== undefined) { + formData.append('name', String(propsValue.name)); + } + formData.append('target_lang', String(propsValue.targetLang)); + if (propsValue.sourceLang !== undefined) { + formData.append('source_lang', String(propsValue.sourceLang)); + } + if (propsValue.numSpeakers !== undefined) { + formData.append('num_speakers', String(propsValue.numSpeakers)); + } + if (propsValue.watermark !== undefined) { + formData.append('watermark', String(propsValue.watermark)); + } + if (propsValue.dropBackgroundAudio !== undefined) { + formData.append('drop_background_audio', String(propsValue.dropBackgroundAudio)); + } + if (propsValue.highestResolution !== undefined) { + formData.append('highest_resolution', String(propsValue.highestResolution)); + } + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/dubbing`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-file-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-file-document.ts new file mode 100644 index 000000000000..42f09e00f51b --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-file-document.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateKbFileDocumentOutputSchema } from '../../output-schemas'; + +export const createKbFileDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_kb_file_document', + outputSchema: elevenlabsCreateKbFileDocumentOutputSchema, + displayName: 'Create KB File Document', + description: 'Add an uploaded file to the knowledge base', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Uploads a file (pdf, txt, docx, html or epub) as a knowledge base document and returns its id. Not idempotent: each call adds another document.', + idempotent: false, + }, + props: { + file: Property.File({ displayName: 'File', description: 'File to add', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'Document name', required: false }), + parentFolderId: Property.ShortText({ displayName: 'Parent Folder ID', description: 'Folder id to place it in, from List KB Documents', required: false }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('file', propsValue.file.data, propsValue.file.filename); + if (propsValue.name !== undefined) { + formData.append('name', String(propsValue.name)); + } + if (propsValue.parentFolderId !== undefined) { + formData.append('parent_folder_id', String(propsValue.parentFolderId)); + } + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/file`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-folder.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-folder.ts new file mode 100644 index 000000000000..b6d2372992e1 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-folder.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateKbFileDocumentOutputSchema } from '../../output-schemas'; + +export const createKbFolder = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_kb_folder', + outputSchema: elevenlabsCreateKbFileDocumentOutputSchema, + displayName: 'Create KB Folder', + description: 'Create a knowledge base folder', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a folder in the knowledge base and returns its id. Not idempotent: each call creates another folder.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Folder name', required: true }), + parentFolderId: Property.ShortText({ displayName: 'Parent Folder ID', description: 'Folder id to place it in, from List KB Documents', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/folder`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, parent_folder_id: propsValue.parentFolderId } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-rag-index.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-rag-index.ts new file mode 100644 index 000000000000..bbd3cf31da6a --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-rag-index.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateKbRagIndexOutputSchema } from '../../output-schemas'; + +export const createKbRagIndex = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_kb_rag_index', + outputSchema: elevenlabsCreateKbRagIndexOutputSchema, + displayName: 'Create KB RAG Index', + description: 'Build a RAG index for a document', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Starts building a RAG index for a knowledge base document with the chosen embedding model and returns the index. Poll Get KB Document RAG Indexes until its status is succeeded. Re-running for the same model returns the existing index.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + model: Property.StaticDropdown({ displayName: 'Model', description: 'Embedding model', required: true, options: { options: [{ label: 'e5_mistral_7b_instruct', value: 'e5_mistral_7b_instruct' }, { label: 'multilingual_e5_large_instruct', value: 'multilingual_e5_large_instruct' }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/rag-index`, + body: elevenlabsClient.compact({ values: { model: propsValue.model } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-text-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-text-document.ts new file mode 100644 index 000000000000..541902484070 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-text-document.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateKbFileDocumentOutputSchema } from '../../output-schemas'; + +export const createKbTextDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_kb_text_document', + outputSchema: elevenlabsCreateKbFileDocumentOutputSchema, + displayName: 'Create KB Text Document', + description: 'Add text to the knowledge base', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Adds a text snippet as a knowledge base document and returns its id. Not idempotent: each call adds another document.', + idempotent: false, + }, + props: { + text: Property.LongText({ displayName: 'Text', description: 'The document text', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'Document name', required: false }), + parentFolderId: Property.ShortText({ displayName: 'Parent Folder ID', description: 'Folder id to place it in, from List KB Documents', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/text`, + body: elevenlabsClient.compact({ values: { text: propsValue.text, name: propsValue.name, parent_folder_id: propsValue.parentFolderId } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-url-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-url-document.ts new file mode 100644 index 000000000000..cc7c709d6c47 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-kb-url-document.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateKbFileDocumentOutputSchema } from '../../output-schemas'; + +export const createKbUrlDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_kb_url_document', + outputSchema: elevenlabsCreateKbFileDocumentOutputSchema, + displayName: 'Create KB URL Document', + description: 'Add a web page to the knowledge base', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Scrapes a web page and adds it as a knowledge base document, returning its id. Not idempotent: each call adds another document.', + idempotent: false, + }, + props: { + url: Property.ShortText({ displayName: 'Url', description: 'Page URL', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'Document name', required: false }), + parentFolderId: Property.ShortText({ displayName: 'Parent Folder ID', description: 'Folder id to place it in, from List KB Documents', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/url`, + body: elevenlabsClient.compact({ values: { url: propsValue.url, name: propsValue.name, parent_folder_id: propsValue.parentFolderId } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-file.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-file.ts new file mode 100644 index 000000000000..50761e0c2428 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-file.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreatePronunciationDictionaryFromRulesOutputSchema } from '../../output-schemas'; + +export const createPronunciationDictionaryFromFile = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_pronunciation_dictionary_from_file', + outputSchema: elevenlabsCreatePronunciationDictionaryFromRulesOutputSchema, + displayName: 'Create Pronunciation Dictionary From File', + description: 'Create a pronunciation dictionary from a PLS file', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a pronunciation dictionary from an uploaded PLS file and returns its id and version_id. Not idempotent: each call creates another dictionary.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Dictionary name', required: true }), + file: Property.File({ displayName: 'File', description: 'PLS file with the rules', required: false }), + description: Property.ShortText({ displayName: 'Description', required: false }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('name', String(propsValue.name)); + if (propsValue.file) { + formData.append('file', propsValue.file.data, propsValue.file.filename); + } + if (propsValue.description !== undefined) { + formData.append('description', String(propsValue.description)); + } + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/pronunciation-dictionaries/add-from-file`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-rules.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-rules.ts new file mode 100644 index 000000000000..5607dc5cefd4 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-pronunciation-dictionary-from-rules.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreatePronunciationDictionaryFromRulesOutputSchema } from '../../output-schemas'; + +export const createPronunciationDictionaryFromRules = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_pronunciation_dictionary_from_rules', + outputSchema: elevenlabsCreatePronunciationDictionaryFromRulesOutputSchema, + displayName: 'Create Pronunciation Dictionary From Rules', + description: 'Create a pronunciation dictionary from rules', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a pronunciation dictionary from a list of alias or phoneme rules and returns its id and version_id. Not idempotent: each call creates another dictionary.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Dictionary name', required: true }), + rules: Property.Json({ displayName: 'Rules', description: 'JSON array of rules, such as [{"type": "alias", "string_to_replace": "AP", "alias": "Activepieces"}] or {"type": "phoneme", "string_to_replace": "...", "phoneme": "...", "alphabet": "ipa"}', required: true }), + description: Property.ShortText({ displayName: 'Description', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/pronunciation-dictionaries/add-from-rules`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, rules: propsValue.rules, description: propsValue.description } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-secret.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-secret.ts new file mode 100644 index 000000000000..1f5b9997958f --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-secret.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateSecretOutputSchema } from '../../output-schemas'; + +export const createSecret = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_secret', + outputSchema: elevenlabsCreateSecretOutputSchema, + displayName: 'Create Secret', + description: 'Create a workspace secret', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Stores a secret value for use by agent tools and returns its secret_id. The value cannot be read back. Not idempotent: each call creates another secret.', + idempotent: false, + }, + props: { + name: Property.ShortText({ displayName: 'Name', description: 'Secret name', required: true }), + value: Property.ShortText({ displayName: 'Value', description: 'Secret value', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/secrets`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, value: propsValue.value, type: 'new' } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-tool.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-tool.ts new file mode 100644 index 000000000000..57e56a79eb7e --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/create-tool.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateToolOutputSchema } from '../../output-schemas'; + +export const createTool = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_create_tool', + outputSchema: elevenlabsCreateToolOutputSchema, + displayName: 'Create Tool', + description: 'Create an agent tool', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a webhook, client or system tool for agents from a tool_config JSON and returns its id. Not idempotent: each call creates another tool.', + idempotent: false, + }, + props: { + toolConfig: Property.Json({ displayName: 'Tool Config', description: 'Tool configuration, such as {"type": "webhook", "name": "lookup_order", "description": "...", "api_schema": {"url": "https://...", "method": "GET"}}', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/tools`, + body: elevenlabsClient.compact({ values: { tool_config: propsValue.toolConfig } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-dubbing.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-dubbing.ts new file mode 100644 index 000000000000..da395bb73b83 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-dubbing.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteDubbingOutputSchema } from '../../output-schemas'; + +export const deleteDubbing = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_delete_dubbing', + outputSchema: elevenlabsDeleteDubbingOutputSchema, + displayName: 'Delete Dubbing', + description: 'Permanently delete a dubbing project', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Permanently deletes a dubbing project and its results. Cannot be undone; a second call on the same id fails.', + idempotent: false, + }, + props: { + dubbingId: Property.ShortText({ displayName: 'Dubbing ID', description: 'The dubbing_id from List Dubbings', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.DELETE, + path: `/v1/dubbing/${encodeURIComponent(propsValue.dubbingId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-document.ts new file mode 100644 index 000000000000..30aad42d9476 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-document.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const deleteKbDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_delete_kb_document', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Delete KB Document', + description: 'Delete a knowledge base document or folder', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Permanently deletes a knowledge base document or folder. Set force to delete it even when agents depend on it. A second call on the same id fails.', + idempotent: false, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + force: Property.StaticDropdown({ displayName: 'Force', description: 'Delete even if agents use it', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.DELETE, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}`, + queryParams: { force: propsValue.force }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-rag-index.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-rag-index.ts new file mode 100644 index 000000000000..c3ba6be20c19 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-kb-rag-index.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const deleteKbRagIndex = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_delete_kb_rag_index', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Delete KB RAG Index', + description: 'Delete a RAG index', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Deletes one RAG index of a knowledge base document. The rag_index_id comes from Get KB Document RAG Indexes.', + idempotent: false, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + ragIndexId: Property.ShortText({ displayName: 'Rag Index ID', description: 'The index id from Get KB Document RAG Indexes', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.DELETE, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/rag-index/${encodeURIComponent(propsValue.ragIndexId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-secret.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-secret.ts new file mode 100644 index 000000000000..19df63d421c8 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-secret.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const deleteSecret = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_delete_secret', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Delete Secret', + description: 'Delete a workspace secret', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Permanently deletes a workspace secret. A second call on the same id fails.', + idempotent: false, + }, + props: { + secretId: Property.ShortText({ displayName: 'Secret ID', description: 'The secret_id from List Secrets', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.DELETE, + path: `/v1/convai/secrets/${encodeURIComponent(propsValue.secretId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-tool.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-tool.ts new file mode 100644 index 000000000000..71a98dc2e008 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/delete-tool.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const deleteTool = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_delete_tool', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Delete Tool', + description: 'Delete an agent tool', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Permanently deletes a tool. Set force to delete it even when agents use it. A second call on the same id fails.', + idempotent: false, + }, + props: { + toolId: Property.ShortText({ displayName: 'Tool ID', description: 'The tool id from List Tools or Create Tool', required: true }), + force: Property.StaticDropdown({ displayName: 'Force', description: 'Delete even if agents use it', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.DELETE, + path: `/v1/convai/tools/${encodeURIComponent(propsValue.toolId)}`, + queryParams: { force: propsValue.force }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/download-history-items.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/download-history-items.ts new file mode 100644 index 000000000000..943c5578cb56 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/download-history-items.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const downloadHistoryItems = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_download_history_items', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Download History Items', + description: 'Download generated audio as a file', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Downloads one or more generated audio items and returns them as a file (a single audio file for one item, a zip for several). The history_item_ids come from List History Items.', + idempotent: true, + }, + props: { + historyItemIds: Property.Array({ displayName: 'History Item Ids', description: 'The history_item_ids from List History Items', required: true }), + outputFormat: Property.StaticDropdown({ displayName: 'Output Format', description: 'Leave empty to keep the original format', required: false, options: { options: [{ label: 'wav', value: 'wav' }] } }), + }, + async run({ auth, propsValue, files }) { + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.POST, + path: `/v1/history/download`, + body: elevenlabsClient.compact({ values: { history_item_ids: propsValue.historyItemIds, output_format: propsValue.outputFormat } }), + }); + const file = await files.write({ fileName: `history.${elevenlabsClient.extensionFor({ contentType: audio.contentType })}`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/duplicate-agent.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/duplicate-agent.ts new file mode 100644 index 000000000000..cd4a67c57277 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/duplicate-agent.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateAgentOutputSchema } from '../../output-schemas'; + +export const duplicateAgent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_duplicate_agent', + outputSchema: elevenlabsCreateAgentOutputSchema, + displayName: 'Duplicate Agent', + description: 'Copy an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Creates a copy of an agent, optionally under a new name, and returns the new agent_id. Not idempotent: each call creates another agent.', + idempotent: false, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'Name of the copy', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/duplicate`, + body: elevenlabsClient.compact({ values: { name: propsValue.name } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/find-similar-voices.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/find-similar-voices.ts new file mode 100644 index 000000000000..d84c8102d981 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/find-similar-voices.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsFindSimilarVoicesOutputSchema } from '../../output-schemas'; + +export const findSimilarVoices = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_find_similar_voices', + outputSchema: elevenlabsFindSimilarVoicesOutputSchema, + displayName: 'Find Similar Voices', + description: 'Find library voices that sound like an audio sample', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Uploads an audio sample and returns public library voices that sound similar to it. Use to pick a stock voice close to a target speaker. Results carry the ids that Add Shared Voice needs.', + idempotent: true, + }, + props: { + audioFile: Property.File({ displayName: 'Audio Sample', required: true }), + similarityThreshold: Property.Number({ displayName: 'Similarity Threshold', description: '0 to 2. Lower returns closer matches only', required: false }), + topK: Property.Number({ displayName: 'Max Results', description: 'Between 1 and 100', required: false }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('audio_file', propsValue.audioFile.data, propsValue.audioFile.filename); + if (propsValue.similarityThreshold !== undefined) { + formData.append('similarity_threshold', String(propsValue.similarityThreshold)); + } + if (propsValue.topK !== undefined) { + formData.append('top_k', String(propsValue.topK)); + } + const response = await elevenlabsClient.request<{ voices: Record[]; has_more: boolean; total_count?: number }>({ + auth, + method: HttpMethod.POST, + path: '/v1/similar-voices', + formData, + }); + return { + voices: response.voices, + count: response.voices.length, + has_more: response.has_more, + total_count: response.total_count ?? null, + }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-knowledge-base-size.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-knowledge-base-size.ts new file mode 100644 index 000000000000..6c12bfed44ea --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-knowledge-base-size.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentKnowledgeBaseSizeOutputSchema } from '../../output-schemas'; + +export const getAgentKnowledgeBaseSize = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent_knowledge_base_size', + outputSchema: elevenlabsGetAgentKnowledgeBaseSizeOutputSchema, + displayName: 'Get Agent Knowledge Base Size', + description: 'Get the knowledge base size of an agent', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the number of pages in the knowledge base attached to an agent.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agent/${encodeURIComponent(propsValue.agentId)}/knowledge-base/size`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-link.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-link.ts new file mode 100644 index 000000000000..52f7c44ba617 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-link.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentLinkOutputSchema } from '../../output-schemas'; + +export const getAgentLink = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent_link', + outputSchema: elevenlabsGetAgentLinkOutputSchema, + displayName: 'Get Agent Link', + description: 'Get a shareable link token for an agent', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the shareable link token of an agent, used to open it in the ElevenLabs web talk page.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/link`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test-summaries.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test-summaries.ts new file mode 100644 index 000000000000..5e95043cd801 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test-summaries.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentTestSummariesOutputSchema } from '../../output-schemas'; + +export const getAgentTestSummaries = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent_test_summaries', + outputSchema: elevenlabsGetAgentTestSummariesOutputSchema, + displayName: 'Get Agent Test Summaries', + description: 'Get summaries of several agent tests', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns short summaries for a list of test ids. Read-only despite using POST.', + idempotent: true, + }, + props: { + testIds: Property.Array({ displayName: 'Test Ids', description: 'Test ids from List Agent Tests', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agent-testing/summaries`, + body: elevenlabsClient.compact({ values: { test_ids: propsValue.testIds } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test.ts new file mode 100644 index 000000000000..7354cf19195d --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-test.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentTestOutputSchema } from '../../output-schemas'; + +export const getAgentTest = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent_test', + outputSchema: elevenlabsGetAgentTestOutputSchema, + displayName: 'Get Agent Test', + description: 'Get an agent test', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns one agent test with its type, success condition and examples.', + idempotent: true, + }, + props: { + testId: Property.ShortText({ displayName: 'Test ID', description: 'The test id from Create Agent Test or List Agent Tests', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agent-testing/${encodeURIComponent(propsValue.testId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-widget.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-widget.ts new file mode 100644 index 000000000000..e82a36f41589 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent-widget.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentWidgetOutputSchema } from '../../output-schemas'; + +export const getAgentWidget = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent_widget', + outputSchema: elevenlabsGetAgentWidgetOutputSchema, + displayName: 'Get Agent Widget', + description: 'Get the embed widget config of an agent', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the website widget configuration of an agent.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/widget`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent.ts new file mode 100644 index 000000000000..56adc3f92ff8 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-agent.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentOutputSchema } from '../../output-schemas'; + +export const getAgent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_agent', + outputSchema: elevenlabsGetAgentOutputSchema, + displayName: 'Get Agent', + description: 'Get a conversational AI agent', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns an agent with its full configuration: prompt, voice, language, tools, phone numbers and platform settings.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-audio-native-settings.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-audio-native-settings.ts new file mode 100644 index 000000000000..bad02bcec446 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-audio-native-settings.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAudioNativeSettingsOutputSchema } from '../../output-schemas'; + +export const getAudioNativeSettings = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_audio_native_settings', + outputSchema: elevenlabsGetAudioNativeSettingsOutputSchema, + displayName: 'Get Audio Native Settings', + description: 'Get the player settings of an Audio Native project', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns whether the Audio Native project is enabled, its current snapshot id and the player settings.', + idempotent: true, + }, + props: { + projectId: Property.ShortText({ displayName: 'Project ID', description: 'The project_id returned by Create Audio Native Project', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/audio-native/${encodeURIComponent(propsValue.projectId)}/settings`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-conversation-signed-url.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-conversation-signed-url.ts new file mode 100644 index 000000000000..3f46ec10f583 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-conversation-signed-url.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetConversationSignedUrlOutputSchema } from '../../output-schemas'; + +export const getConversationSignedUrl = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_conversation_signed_url', + outputSchema: elevenlabsGetConversationSignedUrlOutputSchema, + displayName: 'Get Conversation Signed URL', + description: 'Get a signed URL to start a conversation', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a short-lived signed URL for connecting to a private agent over websocket.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/conversation/get-signed-url`, + queryParams: { agent_id: propsValue.agentId }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-default-voice-settings.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-default-voice-settings.ts new file mode 100644 index 000000000000..b8b5a4e24e7c --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-default-voice-settings.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDefaultVoiceSettingsOutputSchema } from '../../output-schemas'; + +export const getDefaultVoiceSettings = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_default_voice_settings', + outputSchema: elevenlabsGetDefaultVoiceSettingsOutputSchema, + displayName: 'Get Default Voice Settings', + description: 'Get the default text-to-speech voice settings', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the default voice settings (stability, similarity_boost, style, speed, use_speaker_boost) applied to voices that have no custom settings. Use as a reference before tuning a voice.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + return elevenlabsClient.request>({ + auth, + method: HttpMethod.GET, + path: '/v1/voices/settings/default', + }); + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbed-file.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbed-file.ts new file mode 100644 index 000000000000..e8d3ee6a47b8 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbed-file.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const getDubbedFile = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_dubbed_file', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Get Dubbed File', + description: 'Download the dubbed audio or video', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Downloads the finished dub for one language as a file. Only works once Get Dubbing reports status dubbed.', + idempotent: true, + }, + props: { + dubbingId: Property.ShortText({ displayName: 'Dubbing ID', description: 'The dubbing_id returned by Create Dubbing or List Dubbings', required: true }), + languageCode: Property.ShortText({ displayName: 'Language Code', description: 'Target language code, such as es', required: true }), + }, + async run({ auth, propsValue, files }) { + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.GET, + path: `/v1/dubbing/${encodeURIComponent(propsValue.dubbingId)}/audio/${encodeURIComponent(propsValue.languageCode)}`, + }); + const file = await files.write({ fileName: `${propsValue.dubbingId}-${propsValue.languageCode}.${elevenlabsClient.extensionFor({ contentType: audio.contentType })}`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript-formatted.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript-formatted.ts new file mode 100644 index 000000000000..c8ab0425aef4 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript-formatted.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbingTranscriptFormattedOutputSchema } from '../../output-schemas'; + +export const getDubbingTranscriptFormatted = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_dubbing_transcript_formatted', + outputSchema: elevenlabsGetDubbingTranscriptFormattedOutputSchema, + displayName: 'Get Dubbing Transcript Formatted', + description: 'Get a dub transcript in a chosen format', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the transcript of a finished dub for one language in the requested format (srt, webvtt or json).', + idempotent: true, + }, + props: { + dubbingId: Property.ShortText({ displayName: 'Dubbing ID', description: 'The dubbing_id returned by Create Dubbing or List Dubbings', required: true }), + languageCode: Property.ShortText({ displayName: 'Language Code', description: 'Language code, such as es', required: true }), + formatType: Property.ShortText({ displayName: 'Format Type', description: 'One of srt, webvtt or json', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/dubbing/${encodeURIComponent(propsValue.dubbingId)}/transcripts/${encodeURIComponent(propsValue.languageCode)}/format/${encodeURIComponent(propsValue.formatType)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript.ts new file mode 100644 index 000000000000..6b4eec444b08 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing-transcript.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbingTranscriptOutputSchema } from '../../output-schemas'; + +export const getDubbingTranscript = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_dubbing_transcript', + outputSchema: elevenlabsGetDubbingTranscriptOutputSchema, + displayName: 'Get Dubbing Transcript', + description: 'Get the transcript of a dub', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the transcript of a finished dub for one language, as structured utterances or as srt or webvtt text.', + idempotent: true, + }, + props: { + dubbingId: Property.ShortText({ displayName: 'Dubbing ID', description: 'The dubbing_id returned by Create Dubbing or List Dubbings', required: true }), + languageCode: Property.ShortText({ displayName: 'Language Code', description: 'Language code, such as es', required: true }), + formatType: Property.StaticDropdown({ displayName: 'Format Type', required: false, options: { options: [{ label: 'srt', value: 'srt' }, { label: 'webvtt', value: 'webvtt' }, { label: 'json', value: 'json' }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/dubbing/${encodeURIComponent(propsValue.dubbingId)}/transcript/${encodeURIComponent(propsValue.languageCode)}`, + queryParams: { format_type: propsValue.formatType }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing.ts new file mode 100644 index 000000000000..3e46becc4bf9 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-dubbing.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbingOutputSchema } from '../../output-schemas'; + +export const getDubbing = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_dubbing', + outputSchema: elevenlabsGetDubbingOutputSchema, + displayName: 'Get Dubbing', + description: 'Get the status of a dubbing job', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a dubbing job with its status (dubbing, dubbed or failed), languages and any error. Poll this after Create Dubbing.', + idempotent: true, + }, + props: { + dubbingId: Property.ShortText({ displayName: 'Dubbing ID', description: 'The dubbing_id returned by Create Dubbing or List Dubbings', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/dubbing/${encodeURIComponent(propsValue.dubbingId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item-audio.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item-audio.ts new file mode 100644 index 000000000000..48e1ef9ea9c2 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item-audio.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const getHistoryItemAudio = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_history_item_audio', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Get History Item Audio', + description: 'Download the audio of a history item', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Downloads the audio of one generated item and returns it as a file. The history_item_id comes from List History Items.', + idempotent: true, + }, + props: { + historyItemId: Property.ShortText({ displayName: 'History Item ID', description: 'The history_item_id from List History Items', required: true }), + }, + async run({ auth, propsValue, files }) { + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.GET, + path: `/v1/history/${encodeURIComponent(propsValue.historyItemId)}/audio`, + }); + const file = await files.write({ fileName: `${propsValue.historyItemId}.${elevenlabsClient.extensionFor({ contentType: audio.contentType })}`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item.ts new file mode 100644 index 000000000000..510865190156 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-history-item.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetHistoryItemOutputSchema } from '../../output-schemas'; + +export const getHistoryItem = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_history_item', + outputSchema: elevenlabsGetHistoryItemOutputSchema, + displayName: 'Get History Item', + description: 'Get one generated audio item', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the metadata of one generated audio item: text, voice, model, character count, state and settings. The history_item_id comes from List History Items.', + idempotent: true, + }, + props: { + historyItemId: Property.ShortText({ displayName: 'History Item ID', description: 'The history_item_id from List History Items', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/history/${encodeURIComponent(propsValue.historyItemId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-content.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-content.ts new file mode 100644 index 000000000000..6a55aee6e42a --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-content.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbDocumentContentOutputSchema } from '../../output-schemas'; + +export const getKbDocumentContent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_document_content', + outputSchema: elevenlabsGetKbDocumentContentOutputSchema, + displayName: 'Get KB Document Content', + description: 'Get the text content of a knowledge base document', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the extracted text content of a knowledge base document.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/content`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-rag-indexes.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-rag-indexes.ts new file mode 100644 index 000000000000..e395669c9f5c --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-rag-indexes.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbDocumentRagIndexesOutputSchema } from '../../output-schemas'; + +export const getKbDocumentRagIndexes = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_document_rag_indexes', + outputSchema: elevenlabsGetKbDocumentRagIndexesOutputSchema, + displayName: 'Get KB Document RAG Indexes', + description: 'List the RAG indexes of a document', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the RAG indexes of a knowledge base document with their model and build status. Poll this after Create KB RAG Index.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/rag-index`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-source-url.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-source-url.ts new file mode 100644 index 000000000000..083ab4943828 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document-source-url.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbDocumentSourceUrlOutputSchema } from '../../output-schemas'; + +export const getKbDocumentSourceUrl = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_document_source_url', + outputSchema: elevenlabsGetKbDocumentSourceUrlOutputSchema, + displayName: 'Get KB Document Source URL', + description: 'Get a signed URL of the original file', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a signed URL to download the original file of an uploaded knowledge base document.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/source-file-url`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document.ts new file mode 100644 index 000000000000..7830d3bd20d9 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-document.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbDocumentOutputSchema } from '../../output-schemas'; + +export const getKbDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_document', + outputSchema: elevenlabsGetKbDocumentOutputSchema, + displayName: 'Get KB Document', + description: 'Get a knowledge base document', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a knowledge base document with its type, metadata and folder path.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-rag-index-overview.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-rag-index-overview.ts new file mode 100644 index 000000000000..2c8dfe1b86d5 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-rag-index-overview.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbRagIndexOverviewOutputSchema } from '../../output-schemas'; + +export const getKbRagIndexOverview = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_rag_index_overview', + outputSchema: elevenlabsGetKbRagIndexOverviewOutputSchema, + displayName: 'Get KB RAG Index Overview', + description: 'Get RAG index storage usage', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the total RAG index storage used and available, per embedding model.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/rag-index`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-summaries.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-summaries.ts new file mode 100644 index 000000000000..4eae5cac6ef4 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-kb-summaries.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbSummariesOutputSchema } from '../../output-schemas'; + +export const getKbSummaries = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_kb_summaries', + outputSchema: elevenlabsGetKbSummariesOutputSchema, + displayName: 'Get KB Summaries', + description: 'Get the summary of a knowledge base document', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the summary of one knowledge base document by id.', + idempotent: true, + }, + props: { + documentId: Property.ShortText({ displayName: 'Document ID', description: 'A document id from List KB Documents', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/summaries`, + queryParams: { document_ids: propsValue.documentId }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-live-conversation-count.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-live-conversation-count.ts new file mode 100644 index 000000000000..29d90adeb726 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-live-conversation-count.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetLiveConversationCountOutputSchema } from '../../output-schemas'; + +export const getLiveConversationCount = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_live_conversation_count', + outputSchema: elevenlabsGetLiveConversationCountOutputSchema, + displayName: 'Get Live Conversation Count', + description: 'Count the conversations happening now', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns how many conversations are live right now, optionally for one agent.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'Only this agent', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/analytics/live-count`, + queryParams: { agent_id: propsValue.agentId }, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary-version.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary-version.ts new file mode 100644 index 000000000000..a65bd8b086f3 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary-version.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const getPronunciationDictionaryVersion = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_pronunciation_dictionary_version', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Get Pronunciation Dictionary Version', + description: 'Download the PLS file of a dictionary version', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Downloads the PLS file with the rules of one pronunciation dictionary version as a file. The version_id comes from the dictionary actions.', + idempotent: true, + }, + props: { + dictionaryId: Property.ShortText({ displayName: 'Dictionary ID', description: 'The dictionary id from List Pronunciation Dictionaries', required: true }), + versionId: Property.ShortText({ displayName: 'Version ID', description: 'The version_id of the dictionary', required: true }), + }, + async run({ auth, propsValue, files }) { + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.GET, + path: `/v1/pronunciation-dictionaries/${encodeURIComponent(propsValue.dictionaryId)}/${encodeURIComponent(propsValue.versionId)}/download`, + }); + const file = await files.write({ fileName: `${propsValue.versionId}.pls`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary.ts new file mode 100644 index 000000000000..e1bc0d4bea72 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-pronunciation-dictionary.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetPronunciationDictionaryOutputSchema } from '../../output-schemas'; + +export const getPronunciationDictionary = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_pronunciation_dictionary', + outputSchema: elevenlabsGetPronunciationDictionaryOutputSchema, + displayName: 'Get Pronunciation Dictionary', + description: 'Get a pronunciation dictionary', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a pronunciation dictionary with its latest version_id and rules.', + idempotent: true, + }, + props: { + pronunciationDictionaryId: Property.ShortText({ displayName: 'Pronunciation Dictionary ID', description: 'The id from Create Pronunciation Dictionary or List Pronunciation Dictionaries', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/pronunciation-dictionaries/${encodeURIComponent(propsValue.pronunciationDictionaryId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-subscription.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-subscription.ts new file mode 100644 index 000000000000..c776924a12dc --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-subscription.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetSubscriptionOutputSchema } from '../../output-schemas'; + +export const getSubscription = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_subscription', + outputSchema: elevenlabsGetSubscriptionOutputSchema, + displayName: 'Get Subscription', + description: 'Get the account subscription and usage', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the subscription tier and usage: characters used and remaining, voice slots, and the reset date. Use to check remaining credits before large generations.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/user/subscription`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-test-invocation.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-test-invocation.ts new file mode 100644 index 000000000000..465199d7a82c --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-test-invocation.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetTestInvocationOutputSchema } from '../../output-schemas'; + +export const getTestInvocation = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_test_invocation', + outputSchema: elevenlabsGetTestInvocationOutputSchema, + displayName: 'Get Test Invocation', + description: 'Get the results of a test run', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns a test invocation with the result of each test run. Poll this after Run Agent Tests.', + idempotent: true, + }, + props: { + testInvocationId: Property.ShortText({ displayName: 'Test Invocation ID', description: 'The id from Run Agent Tests or List Test Invocations', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/test-invocations/${encodeURIComponent(propsValue.testInvocationId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-tool.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-tool.ts new file mode 100644 index 000000000000..2e3156e5c996 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-tool.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateToolOutputSchema } from '../../output-schemas'; + +export const getTool = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_tool', + outputSchema: elevenlabsCreateToolOutputSchema, + displayName: 'Get Tool', + description: 'Get an agent tool', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns one tool with its configuration and usage stats.', + idempotent: true, + }, + props: { + toolId: Property.ShortText({ displayName: 'Tool ID', description: 'The tool id from List Tools or Create Tool', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/tools/${encodeURIComponent(propsValue.toolId)}`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-user.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-user.ts new file mode 100644 index 000000000000..7fbbeb6c5614 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-user.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetUserOutputSchema } from '../../output-schemas'; + +export const getUser = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_user', + outputSchema: elevenlabsGetUserOutputSchema, + displayName: 'Get User', + description: 'Get the current account user', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the user behind the API key: user id, name, API key preview and subscription summary. Use to check which account a connection belongs to.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.GET, + path: `/v1/user`, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice-settings.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice-settings.ts new file mode 100644 index 000000000000..5a590ea43011 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice-settings.ts @@ -0,0 +1,29 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDefaultVoiceSettingsOutputSchema } from '../../output-schemas'; + +export const getVoiceSettings = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_voice_settings', + outputSchema: elevenlabsGetDefaultVoiceSettingsOutputSchema, + displayName: 'Get Voice Settings', + description: 'Get the stability, similarity, style and speed settings of a voice', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Returns the current text-to-speech settings of a voice: stability, similarity_boost, style, speed and use_speaker_boost. Use before Edit Voice Settings to see what will change.', + idempotent: true, + }, + props: { + voiceId: Property.ShortText({ displayName: 'Voice ID', description: 'The voice_id from List Voices', required: true }), + }, + async run({ auth, propsValue }) { + return elevenlabsClient.request>({ + auth, + method: HttpMethod.GET, + path: `/v1/voices/${encodeURIComponent(propsValue.voiceId)}/settings`, + }); + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice.ts new file mode 100644 index 000000000000..61330e4666f5 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/get-voice.ts @@ -0,0 +1,30 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetVoiceOutputSchema } from '../../output-schemas'; + +export const getVoice = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_get_voice', + outputSchema: elevenlabsGetVoiceOutputSchema, + displayName: 'Get Voice', + description: 'Get the details of a voice', + audience: 'ai', + classification: 'READ', + aiMetadata: { + description: 'Fetches one voice by voice_id, including its name, category, labels, samples, settings and preview URL. Use after List Voices to inspect a voice before using or editing it.', + idempotent: true, + }, + props: { + voiceId: Property.ShortText({ displayName: 'Voice ID', description: 'The voice_id from List Voices', required: true }), + }, + async run({ auth, propsValue }) { + return elevenlabsClient.request>({ + auth, + method: HttpMethod.GET, + path: `/v1/voices/${encodeURIComponent(propsValue.voiceId)}`, + queryParams: { with_settings: true }, + }); + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/isolate-audio.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/isolate-audio.ts new file mode 100644 index 000000000000..3337c71f1698 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/isolate-audio.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetDubbedFileOutputSchema } from '../../output-schemas'; + +export const isolateAudio = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_isolate_audio', + outputSchema: elevenlabsGetDubbedFileOutputSchema, + displayName: 'Isolate Audio', + description: 'Remove background noise and keep only the voice', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Removes background noise and music from an audio file, keeping the speech, and returns the cleaned audio as a file. Use to clean a recording before cloning a voice or transcribing it. Not idempotent: each call consumes credits.', + idempotent: false, + }, + props: { + audio: Property.File({ displayName: 'Audio', description: 'Recording to clean', required: true }), + }, + async run({ auth, propsValue, files }) { + const formData = new FormData(); + formData.append('audio', propsValue.audio.data, propsValue.audio.filename); + const audio = await elevenlabsClient.download({ + auth, + method: HttpMethod.POST, + path: `/v1/audio-isolation`, + formData, + }); + const file = await files.write({ fileName: `isolated.${elevenlabsClient.extensionFor({ contentType: audio.contentType })}`, data: audio.data }); + return { file, content_type: audio.contentType }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agent-tests.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agent-tests.ts new file mode 100644 index 000000000000..5b705fa626b7 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agent-tests.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListAgentTestsOutputSchema } from '../../output-schemas'; + +export const listAgentTests = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_agent_tests', + outputSchema: elevenlabsListAgentTestsOutputSchema, + displayName: 'List Agent Tests', + description: 'List agent tests', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists agent tests with their ids and names. Search by name and page with next_cursor. Use to find test ids for Run Agent Tests.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against the test name', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { tests: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agent-testing`, + queryParams: { search: propsValue.search, page_size: propsValue.pageSize, cursor: propsValue.cursor }, + }); + return { ...response, count: response.tests.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agents.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agents.ts new file mode 100644 index 000000000000..d687416dc5fe --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-agents.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListAgentsOutputSchema } from '../../output-schemas'; + +export const listAgents = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_agents', + outputSchema: elevenlabsListAgentsOutputSchema, + displayName: 'List Agents', + description: 'List conversational AI agents', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists agents with their ids and names. Search by name and page with next_cursor. Use to find an agent_id.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against the agent name', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + archived: Property.StaticDropdown({ displayName: 'Archived', description: 'Only archived agents', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + sortDirection: Property.StaticDropdown({ displayName: 'Sort Direction', required: false, options: { options: [{ label: 'asc', value: 'asc' }, { label: 'desc', value: 'desc' }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { agents: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/agents`, + queryParams: { search: propsValue.search, page_size: propsValue.pageSize, cursor: propsValue.cursor, archived: propsValue.archived, sort_direction: propsValue.sortDirection }, + }); + return { ...response, count: response.agents.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-batch-calls.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-batch-calls.ts new file mode 100644 index 000000000000..29e1beb22ae8 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-batch-calls.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListBatchCallsOutputSchema } from '../../output-schemas'; + +export const listBatchCalls = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_batch_calls', + outputSchema: elevenlabsListBatchCallsOutputSchema, + displayName: 'List Batch Calls', + description: 'List batch calls', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists batch calls in the workspace with status and progress counts. Page with next_doc.', + idempotent: true, + }, + props: { + limit: Property.Number({ displayName: 'Limit', description: 'Between 1 and 100', required: false }), + lastDoc: Property.ShortText({ displayName: 'Last Doc', description: 'next_doc from a previous call', required: false }), + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'Only this agent', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { batch_calls: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/batch-calling/workspace`, + queryParams: { limit: propsValue.limit, last_doc: propsValue.lastDoc, agent_id: propsValue.agentId }, + }); + return { ...response, count: response.batch_calls.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-conversations.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-conversations.ts new file mode 100644 index 000000000000..58edc8152646 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-conversations.ts @@ -0,0 +1,36 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListConversationsOutputSchema } from '../../output-schemas'; + +export const listConversations = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_conversations', + outputSchema: elevenlabsListConversationsOutputSchema, + displayName: 'List Conversations', + description: 'List agent conversations', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists conversations with their ids, agent, duration and success status. Filter by agent, outcome or start time and page with next_cursor. Use to find a conversation_id.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'Only this agent', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + callSuccessful: Property.StaticDropdown({ displayName: 'Call Successful', required: false, options: { options: [{ label: 'success', value: 'success' }, { label: 'failure', value: 'failure' }, { label: 'unknown', value: 'unknown' }] } }), + callStartAfterUnix: Property.Number({ displayName: 'Call Start After Unix', description: 'Unix time lower bound', required: false }), + callStartBeforeUnix: Property.Number({ displayName: 'Call Start Before Unix', description: 'Unix time upper bound', required: false }), + userId: Property.ShortText({ displayName: 'User ID', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { conversations: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/conversations`, + queryParams: { agent_id: propsValue.agentId, cursor: propsValue.cursor, call_successful: propsValue.callSuccessful, call_start_after_unix: propsValue.callStartAfterUnix, call_start_before_unix: propsValue.callStartBeforeUnix, user_id: propsValue.userId }, + }); + return { ...response, count: response.conversations.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-dubbings.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-dubbings.ts new file mode 100644 index 000000000000..b83b236f0bac --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-dubbings.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListDubbingsOutputSchema } from '../../output-schemas'; + +export const listDubbings = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_dubbings', + outputSchema: elevenlabsListDubbingsOutputSchema, + displayName: 'List Dubbings', + description: 'List dubbing projects', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists dubbing projects with their ids, names and status. Filter by status and page with next_cursor. Use to find a dubbing_id.', + idempotent: true, + }, + props: { + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 200', required: false }), + dubbingStatus: Property.StaticDropdown({ displayName: 'Dubbing Status', required: false, options: { options: [{ label: 'dubbing', value: 'dubbing' }, { label: 'dubbed', value: 'dubbed' }, { label: 'failed', value: 'failed' }] } }), + orderBy: Property.StaticDropdown({ displayName: 'Order By', required: false, options: { options: [{ label: 'created_at', value: 'created_at' }, { label: 'name', value: 'name' }] } }), + orderDirection: Property.StaticDropdown({ displayName: 'Order Direction', required: false, options: { options: [{ label: 'DESCENDING', value: 'DESCENDING' }, { label: 'ASCENDING', value: 'ASCENDING' }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { dubs: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/dubbing`, + queryParams: { cursor: propsValue.cursor, page_size: propsValue.pageSize, dubbing_status: propsValue.dubbingStatus, order_by: propsValue.orderBy, order_direction: propsValue.orderDirection }, + }); + return { ...response, count: response.dubs.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-history-items.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-history-items.ts new file mode 100644 index 000000000000..bb055fcd6b67 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-history-items.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListHistoryItemsOutputSchema } from '../../output-schemas'; + +export const listHistoryItems = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_history_items', + outputSchema: elevenlabsListHistoryItemsOutputSchema, + displayName: 'List History Items', + description: 'List previously generated audio', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists generated audio (text-to-speech, speech-to-speech) from the account history with the text, voice and model used. Filter by voice, model, source or search text and page with last_history_item_id. Use to find a history_item_id for Get History Item Audio.', + idempotent: true, + }, + props: { + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 1000. Defaults to 100', required: false }), + startAfterHistoryItemId: Property.ShortText({ displayName: 'Start After History Item ID', description: 'last_history_item_id from a previous call to get the next page', required: false }), + voiceId: Property.ShortText({ displayName: 'Voice ID', description: 'Only items generated with this voice', required: false }), + modelId: Property.ShortText({ displayName: 'Model ID', description: 'Only items generated with this model', required: false }), + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against the generated text', required: false }), + source: Property.StaticDropdown({ displayName: 'Source', required: false, options: { options: [{ label: 'TTS', value: 'TTS' }, { label: 'STS', value: 'STS' }, { label: 'Flows', value: 'Flows' }] } }), + sortDirection: Property.StaticDropdown({ displayName: 'Sort Direction', required: false, options: { options: [{ label: 'asc', value: 'asc' }, { label: 'desc', value: 'desc' }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { history: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/history`, + queryParams: { page_size: propsValue.pageSize, start_after_history_item_id: propsValue.startAfterHistoryItemId, voice_id: propsValue.voiceId, model_id: propsValue.modelId, search: propsValue.search, source: propsValue.source, sort_direction: propsValue.sortDirection }, + }); + return { ...response, count: response.history.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-dependent-agents.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-dependent-agents.ts new file mode 100644 index 000000000000..c697525ea1cd --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-dependent-agents.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListKbDependentAgentsOutputSchema } from '../../output-schemas'; + +export const listKbDependentAgents = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_kb_dependent_agents', + outputSchema: elevenlabsListKbDependentAgentsOutputSchema, + displayName: 'List KB Dependent Agents', + description: 'List agents using a document', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists the agents that use a knowledge base document. Check this before deleting a document.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + dependentType: Property.StaticDropdown({ displayName: 'Dependent Type', required: false, options: { options: [{ label: 'direct', value: 'direct' }, { label: 'transitive', value: 'transitive' }, { label: 'all', value: 'all' }] } }), + pageSize: Property.Number({ displayName: 'Page Size', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { agents: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}/dependent-agents`, + queryParams: { dependent_type: propsValue.dependentType, page_size: propsValue.pageSize, cursor: propsValue.cursor }, + }); + return { ...response, count: response.agents.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-documents.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-documents.ts new file mode 100644 index 000000000000..368ba4b539ec --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-kb-documents.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListKbDocumentsOutputSchema } from '../../output-schemas'; + +export const listKbDocuments = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_kb_documents', + outputSchema: elevenlabsListKbDocumentsOutputSchema, + displayName: 'List KB Documents', + description: 'List knowledge base documents', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists knowledge base documents and folders with their ids, names and types. Search by name and page with next_cursor. Use to find a documentation_id.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against the document name', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + parentFolderId: Property.ShortText({ displayName: 'Parent Folder ID', description: 'Only items in this folder', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { documents: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/knowledge-base`, + queryParams: { search: propsValue.search, page_size: propsValue.pageSize, cursor: propsValue.cursor, parent_folder_id: propsValue.parentFolderId }, + }); + return { ...response, count: response.documents.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-models.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-models.ts new file mode 100644 index 000000000000..13ac4ff43b30 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-models.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListModelsOutputSchema } from '../../output-schemas'; + +export const listModels = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_models', + outputSchema: elevenlabsListModelsOutputSchema, + displayName: 'List Models', + description: 'List the available ElevenLabs models', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists every model with its id, supported languages, character limits and capabilities such as text-to-speech or voice conversion. Use to find the model_id that other actions need.', + idempotent: true, + }, + props: {}, + async run({ auth }) { + const response = await elevenlabsClient.request({ + auth, + method: HttpMethod.GET, + path: `/v1/models`, + }); + return { items: response, count: response.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-phone-numbers.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-phone-numbers.ts new file mode 100644 index 000000000000..a162c82cd97f --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-phone-numbers.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListPhoneNumbersOutputSchema } from '../../output-schemas'; + +export const listPhoneNumbers = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_phone_numbers', + outputSchema: elevenlabsListPhoneNumbersOutputSchema, + displayName: 'List Phone Numbers', + description: 'List agent phone numbers', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists imported phone numbers with the agent each is assigned to. The phone_number_id feeds Make Outbound Call and Submit Batch Call.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'Only numbers assigned to this agent', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/phone-numbers`, + queryParams: { agent_id: propsValue.agentId }, + }); + return { items: response, count: response.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-pronunciation-dictionaries.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-pronunciation-dictionaries.ts new file mode 100644 index 000000000000..868df0b94e3a --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-pronunciation-dictionaries.ts @@ -0,0 +1,34 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListPronunciationDictionariesOutputSchema } from '../../output-schemas'; + +export const listPronunciationDictionaries = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_pronunciation_dictionaries', + outputSchema: elevenlabsListPronunciationDictionariesOutputSchema, + displayName: 'List Pronunciation Dictionaries', + description: 'List pronunciation dictionaries', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists pronunciation dictionaries with their ids and latest version ids. Page with next_cursor.', + idempotent: true, + }, + props: { + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100', required: false }), + sort: Property.StaticDropdown({ displayName: 'Sort', required: false, options: { options: [{ label: 'creation_time_unix', value: 'creation_time_unix' }, { label: 'name', value: 'name' }] } }), + includeArchived: Property.StaticDropdown({ displayName: 'Include Archived', description: 'Include archived dictionaries', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { pronunciation_dictionaries: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/pronunciation-dictionaries`, + queryParams: { cursor: propsValue.cursor, page_size: propsValue.pageSize, sort: propsValue.sort, include_archived: propsValue.includeArchived }, + }); + return { ...response, count: response.pronunciation_dictionaries.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-secrets.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-secrets.ts new file mode 100644 index 000000000000..b224641a9fb2 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-secrets.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListSecretsOutputSchema } from '../../output-schemas'; + +export const listSecrets = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_secrets', + outputSchema: elevenlabsListSecretsOutputSchema, + displayName: 'List Secrets', + description: 'List workspace secrets', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists workspace secrets with their ids and names. Values are never returned.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { secrets: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/secrets`, + queryParams: { search: propsValue.search, page_size: propsValue.pageSize, cursor: propsValue.cursor }, + }); + return { ...response, count: response.secrets.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-shared-voices.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-shared-voices.ts new file mode 100644 index 000000000000..c8b94b35a1b5 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-shared-voices.ts @@ -0,0 +1,62 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListSharedVoicesOutputSchema } from '../../output-schemas'; + +export const listSharedVoices = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_shared_voices', + outputSchema: elevenlabsListSharedVoicesOutputSchema, + displayName: 'List Shared Voices', + description: 'Search the public ElevenLabs voice library', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Searches the public voice library by text, gender, age, accent, language or category. Returns each voice with its public_owner_id and voice_id, which Add Shared Voice needs to copy it into the account.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', required: false }), + category: Property.StaticDropdown({ + displayName: 'Category', + required: false, + options: { + options: [ + { label: 'Professional', value: 'professional' }, + { label: 'Famous', value: 'famous' }, + { label: 'High quality', value: 'high_quality' }, + ], + }, + }), + gender: Property.ShortText({ displayName: 'Gender', description: 'For example male or female', required: false }), + age: Property.ShortText({ displayName: 'Age', description: 'For example young, middle_aged or old', required: false }), + accent: Property.ShortText({ displayName: 'Accent', required: false }), + language: Property.ShortText({ displayName: 'Language', description: 'Language code such as en or es', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Up to 100. Defaults to 30', required: false }), + page: Property.Number({ displayName: 'Page', description: 'Zero-based page number', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request<{ voices: Record[]; has_more: boolean; total_count?: number }>({ + auth, + method: HttpMethod.GET, + path: '/v1/shared-voices', + queryParams: { + search: propsValue.search, + category: propsValue.category, + gender: propsValue.gender, + age: propsValue.age, + accent: propsValue.accent, + language: propsValue.language, + page_size: propsValue.pageSize, + page: propsValue.page, + }, + }); + return { + voices: response.voices, + count: response.voices.length, + has_more: response.has_more, + total_count: response.total_count ?? null, + }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-test-invocations.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-test-invocations.ts new file mode 100644 index 000000000000..cd7dab46eb42 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-test-invocations.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListTestInvocationsOutputSchema } from '../../output-schemas'; + +export const listTestInvocations = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_test_invocations', + outputSchema: elevenlabsListTestInvocationsOutputSchema, + displayName: 'List Test Invocations', + description: 'List agent test runs', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists past test runs, optionally for one agent. Page with next_cursor.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'Only runs for this agent', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { results: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/test-invocations`, + queryParams: { agent_id: propsValue.agentId, page_size: propsValue.pageSize, cursor: propsValue.cursor }, + }); + return { ...response, count: response.results.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tool-dependent-agents.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tool-dependent-agents.ts new file mode 100644 index 000000000000..c005340c2647 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tool-dependent-agents.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListKbDependentAgentsOutputSchema } from '../../output-schemas'; + +export const listToolDependentAgents = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_tool_dependent_agents', + outputSchema: elevenlabsListKbDependentAgentsOutputSchema, + displayName: 'List Tool Dependent Agents', + description: 'List agents using a tool', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists the agents that use a tool. Check this before deleting a tool.', + idempotent: true, + }, + props: { + toolId: Property.ShortText({ displayName: 'Tool ID', description: 'The tool id from List Tools or Create Tool', required: true }), + cursor: Property.ShortText({ displayName: 'Cursor', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { agents: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/tools/${encodeURIComponent(propsValue.toolId)}/dependent-agents`, + queryParams: { cursor: propsValue.cursor, page_size: propsValue.pageSize }, + }); + return { ...response, count: response.agents.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tools.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tools.ts new file mode 100644 index 000000000000..21e58171e777 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-tools.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListToolsOutputSchema } from '../../output-schemas'; + +export const listTools = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_tools', + outputSchema: elevenlabsListToolsOutputSchema, + displayName: 'List Tools', + description: 'List agent tools', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists workspace agent tools with their ids and configuration. Search by name and page with next_cursor.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against the tool name', required: false }), + pageSize: Property.Number({ displayName: 'Page Size', required: false }), + cursor: Property.ShortText({ displayName: 'Cursor', description: 'next_cursor from a previous call', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request & { tools: unknown[] }>({ + auth, + method: HttpMethod.GET, + path: `/v1/convai/tools`, + queryParams: { search: propsValue.search, page_size: propsValue.pageSize, cursor: propsValue.cursor }, + }); + return { ...response, count: response.tools.length }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-voices.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-voices.ts new file mode 100644 index 000000000000..f4d9a0dac8e1 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/list-voices.ts @@ -0,0 +1,79 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsListVoicesOutputSchema } from '../../output-schemas'; + +export const listVoices = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_list_voices', + outputSchema: elevenlabsListVoicesOutputSchema, + displayName: 'List Voices', + description: 'Search the voices available in your ElevenLabs account', + audience: 'ai', + classification: 'SEARCH', + aiMetadata: { + description: 'Lists or searches the voices in the account (premade, cloned, generated and library voices), returning ids, names, categories and labels. Use to find the voice_id needed by text-to-speech, edit, or delete actions. Supports paging through next_page_token.', + idempotent: true, + }, + props: { + search: Property.ShortText({ displayName: 'Search', description: 'Text matched against voice name, description and labels', required: false }), + category: Property.StaticDropdown({ + displayName: 'Category', + required: false, + options: { + options: [ + { label: 'Premade', value: 'premade' }, + { label: 'Cloned', value: 'cloned' }, + { label: 'Generated', value: 'generated' }, + { label: 'Professional', value: 'professional' }, + ], + }, + }), + voiceType: Property.StaticDropdown({ + displayName: 'Voice Type', + required: false, + options: { + options: [ + { label: 'Personal', value: 'personal' }, + { label: 'Community', value: 'community' }, + { label: 'Default', value: 'default' }, + { label: 'Workspace', value: 'workspace' }, + { label: 'Non-default', value: 'non-default' }, + { label: 'Saved', value: 'saved' }, + ], + }, + }), + pageSize: Property.Number({ displayName: 'Page Size', description: 'Between 1 and 100. Defaults to 10', required: false }), + nextPageToken: Property.ShortText({ displayName: 'Next Page Token', description: 'next_page_token from a previous call', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request({ + auth, + method: HttpMethod.GET, + path: '/v2/voices', + queryParams: { + search: propsValue.search, + category: propsValue.category, + voice_type: propsValue.voiceType, + page_size: propsValue.pageSize, + next_page_token: propsValue.nextPageToken, + include_total_count: true, + }, + }); + return { + voices: response.voices, + count: response.voices.length, + has_more: response.has_more, + total_count: response.total_count ?? null, + next_page_token: response.next_page_token ?? null, + }; + }, +}); + +type VoicesResponse = { + voices: Record[]; + has_more: boolean; + total_count?: number | null; + next_page_token?: string | null; +}; diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-document.ts new file mode 100644 index 000000000000..902580b3a061 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-document.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const moveKbDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_move_kb_document', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Move KB Document', + description: 'Move a document into a folder', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Moves a knowledge base document or folder into another folder. Leave the destination empty to move it to the root. Re-applying the same move is safe.', + idempotent: true, + }, + props: { + documentId: Property.ShortText({ displayName: 'Document ID', description: 'The document id from List KB Documents', required: true }), + moveTo: Property.ShortText({ displayName: 'Move To', description: 'Destination folder id, empty for the root', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentId)}/move`, + body: elevenlabsClient.compact({ values: { move_to: propsValue.moveTo } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-documents-bulk.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-documents-bulk.ts new file mode 100644 index 000000000000..8c04103e85c1 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/move-kb-documents-bulk.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const moveKbDocumentsBulk = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_move_kb_documents_bulk', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Move KB Documents Bulk', + description: 'Move several documents into a folder', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Moves several knowledge base documents into one folder in a single call. Re-applying the same move is safe.', + idempotent: true, + }, + props: { + documentIds: Property.Array({ displayName: 'Document Ids', description: 'Document ids from List KB Documents', required: true }), + moveTo: Property.ShortText({ displayName: 'Move To', description: 'Destination folder id, empty for the root', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/knowledge-base/bulk-move`, + body: elevenlabsClient.compact({ values: { document_ids: propsValue.documentIds, move_to: propsValue.moveTo } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/remove-pronunciation-rules.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/remove-pronunciation-rules.ts new file mode 100644 index 000000000000..ee0b681024f4 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/remove-pronunciation-rules.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsAddPronunciationRulesOutputSchema } from '../../output-schemas'; + +export const removePronunciationRules = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_remove_pronunciation_rules', + outputSchema: elevenlabsAddPronunciationRulesOutputSchema, + displayName: 'Remove Pronunciation Rules', + description: 'Remove rules from a pronunciation dictionary', + audience: 'ai', + classification: 'DESTRUCTIVE', + aiMetadata: { + description: 'Removes rules from a pronunciation dictionary by their string_to_replace values and returns the new version_id. Removing rules that are already gone changes nothing.', + idempotent: true, + }, + props: { + pronunciationDictionaryId: Property.ShortText({ displayName: 'Pronunciation Dictionary ID', description: 'The id from Create Pronunciation Dictionary or List Pronunciation Dictionaries', required: true }), + ruleStrings: Property.Array({ displayName: 'Rule Strings', description: 'The string_to_replace values of the rules to remove', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/pronunciation-dictionaries/${encodeURIComponent(propsValue.pronunciationDictionaryId)}/remove-rules`, + body: elevenlabsClient.compact({ values: { rule_strings: propsValue.ruleStrings } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/resubmit-test-invocation.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/resubmit-test-invocation.ts new file mode 100644 index 000000000000..cb3c34ddbc1f --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/resubmit-test-invocation.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsDeleteKbDocumentOutputSchema } from '../../output-schemas'; + +export const resubmitTestInvocation = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_resubmit_test_invocation', + outputSchema: elevenlabsDeleteKbDocumentOutputSchema, + displayName: 'Resubmit Test Invocation', + description: 'Re-run tests of a previous run', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Re-runs selected test runs of a previous invocation against the agent. Not idempotent: each call starts new runs.', + idempotent: false, + }, + props: { + testInvocationId: Property.ShortText({ displayName: 'Test Invocation ID', description: 'The id from List Test Invocations', required: true }), + testRunIds: Property.Array({ displayName: 'Test Run Ids', description: 'test_run ids from Get Test Invocation', required: true }), + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent to run against', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/test-invocations/${encodeURIComponent(propsValue.testInvocationId)}/resubmit`, + body: elevenlabsClient.compact({ values: { test_run_ids: propsValue.testRunIds, agent_id: propsValue.agentId } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/run-agent-tests.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/run-agent-tests.ts new file mode 100644 index 000000000000..dba334b2d41a --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/run-agent-tests.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsRunAgentTestsOutputSchema } from '../../output-schemas'; + +export const runAgentTests = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_run_agent_tests', + outputSchema: elevenlabsRunAgentTestsOutputSchema, + displayName: 'Run Agent Tests', + description: 'Run tests against an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Starts a run of the given tests against an agent and returns a test invocation. Poll Get Test Invocation until the runs finish. Not idempotent: each call starts a new run.', + idempotent: false, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents', required: true }), + tests: Property.Json({ displayName: 'Tests', description: 'JSON array of tests to run, such as [{"test_id": "..."}]', required: true }), + repeatCount: Property.Number({ displayName: 'Repeat Count', description: 'Times to run each test', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/run-tests`, + body: elevenlabsClient.compact({ values: { tests: propsValue.tests, repeat_count: propsValue.repeatCount } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/set-agent-avatar.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/set-agent-avatar.ts new file mode 100644 index 000000000000..25f80663c42f --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/set-agent-avatar.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsSetAgentAvatarOutputSchema } from '../../output-schemas'; + +export const setAgentAvatar = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_set_agent_avatar', + outputSchema: elevenlabsSetAgentAvatarOutputSchema, + displayName: 'Set Agent Avatar', + description: 'Upload an avatar image for an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Uploads an image as the agent avatar and returns the avatar URL. Uploading the same image again gives the same result.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + avatarFile: Property.File({ displayName: 'Avatar File', description: 'Image file for the avatar', required: true }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('avatar_file', propsValue.avatarFile.data, propsValue.avatarFile.filename); + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/avatar`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/simulate-agent-conversation.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/simulate-agent-conversation.ts new file mode 100644 index 000000000000..df69fe4a3f4e --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/simulate-agent-conversation.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsSimulateAgentConversationOutputSchema } from '../../output-schemas'; + +export const simulateAgentConversation = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_simulate_agent_conversation', + outputSchema: elevenlabsSimulateAgentConversationOutputSchema, + displayName: 'Simulate Agent Conversation', + description: 'Simulate a conversation with an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Runs a simulated conversation between the agent and a simulated user described by simulation_specification, and returns the transcript and analysis. Use to test an agent without a live call. Not idempotent: each run differs and consumes credits.', + idempotent: false, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + simulationSpecification: Property.Json({ displayName: 'Simulation Specification', description: 'Simulated user, such as {"simulated_user_config": {"prompt": {"prompt": "You are a customer asking about pricing"}}}', required: true }), + newTurnsLimit: Property.Number({ displayName: 'New Turns Limit', description: 'Maximum turns to simulate', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}/simulate-conversation`, + body: elevenlabsClient.compact({ values: { simulation_specification: propsValue.simulationSpecification, new_turns_limit: propsValue.newTurnsLimit } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent-test.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent-test.ts new file mode 100644 index 000000000000..59a0a1981bb1 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent-test.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentTestOutputSchema } from '../../output-schemas'; + +export const updateAgentTest = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_agent_test', + outputSchema: elevenlabsGetAgentTestOutputSchema, + displayName: 'Update Agent Test', + description: 'Change an agent test', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Updates an agent test. The API replaces the whole test, so the action reads the current test and re-sends it with only the supplied fields changed. Re-applying the same values is safe.', + idempotent: true, + }, + props: { + testId: Property.ShortText({ displayName: 'Test ID', description: 'The test id from Create Agent Test or List Agent Tests', required: true }), + name: Property.ShortText({ displayName: 'Name', required: false }), + successCondition: Property.LongText({ displayName: 'Success Condition', description: 'What a passing response must do', required: false }), + successExamples: Property.Json({ displayName: 'Success Examples', description: 'Replaces the examples, JSON array such as [{"response": "...", "type": "success"}]', required: false }), + failureExamples: Property.Json({ displayName: 'Failure Examples', description: 'Replaces the examples, JSON array such as [{"response": "...", "type": "failure"}]', required: false }), + }, + async run({ auth, propsValue }) { + const path = `/v1/convai/agent-testing/${encodeURIComponent(propsValue.testId)}`; + const { id: _id, ...current } = await elevenlabsClient.request>({ auth, method: HttpMethod.GET, path }); + return elevenlabsClient.request>({ + auth, + method: HttpMethod.PUT, + path, + body: { + ...current, + ...elevenlabsClient.compact({ + values: { + name: propsValue.name, + success_condition: propsValue.successCondition, + success_examples: propsValue.successExamples, + failure_examples: propsValue.failureExamples, + }, + }), + }, + }); + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent.ts new file mode 100644 index 000000000000..0af04ff14a26 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-agent.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetAgentOutputSchema } from '../../output-schemas'; + +export const updateAgent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_agent', + outputSchema: elevenlabsGetAgentOutputSchema, + displayName: 'Update Agent', + description: 'Change the settings of an agent', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Updates an agent. Only the supplied fields change; conversation_config and platform_settings are merged by the API. Use Get Agent first to see the current values.', + idempotent: true, + }, + props: { + agentId: Property.ShortText({ displayName: 'Agent ID', description: 'The agent_id from List Agents or Create Agent', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'New agent name', required: false }), + conversationConfig: Property.Json({ displayName: 'Conversation Config', description: 'Partial configuration to change, such as {"agent": {"first_message": "Hello"}}', required: false }), + platformSettings: Property.Json({ displayName: 'Platform Settings', description: 'Partial platform settings to change', required: false }), + tags: Property.Array({ displayName: 'Tags', description: 'Replaces the tags', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.PATCH, + path: `/v1/convai/agents/${encodeURIComponent(propsValue.agentId)}`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, conversation_config: propsValue.conversationConfig, platform_settings: propsValue.platformSettings, tags: propsValue.tags } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-audio-native-content.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-audio-native-content.ts new file mode 100644 index 000000000000..7af8bdfe8a6d --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-audio-native-content.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsUpdateAudioNativeContentOutputSchema } from '../../output-schemas'; + +export const updateAudioNativeContent = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_audio_native_content', + outputSchema: elevenlabsUpdateAudioNativeContentOutputSchema, + displayName: 'Update Audio Native Content', + description: 'Replace the article of an Audio Native project', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Replaces the article content of an Audio Native project with an uploaded file, optionally regenerating and publishing the audio. Re-applying the same file gives the same result.', + idempotent: true, + }, + props: { + projectId: Property.ShortText({ displayName: 'Project ID', description: 'The project_id returned by Create Audio Native Project', required: true }), + file: Property.File({ displayName: 'File', description: 'New article as a .txt or .html file', required: true }), + autoConvert: Property.StaticDropdown({ displayName: 'Auto Convert', description: 'Regenerate the audio', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + autoPublish: Property.StaticDropdown({ displayName: 'Auto Publish', description: 'Publish after conversion', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const formData = new FormData(); + formData.append('file', propsValue.file.data, propsValue.file.filename); + if (propsValue.autoConvert !== undefined) { + formData.append('auto_convert', String(propsValue.autoConvert)); + } + if (propsValue.autoPublish !== undefined) { + formData.append('auto_publish', String(propsValue.autoPublish)); + } + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.POST, + path: `/v1/audio-native/${encodeURIComponent(propsValue.projectId)}/content`, + formData, + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-kb-document.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-kb-document.ts new file mode 100644 index 000000000000..4a6c96e65140 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-kb-document.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsGetKbDocumentOutputSchema } from '../../output-schemas'; + +export const updateKbDocument = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_kb_document', + outputSchema: elevenlabsGetKbDocumentOutputSchema, + displayName: 'Update KB Document', + description: 'Rename or edit a knowledge base document', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Renames a knowledge base document or replaces its text content. Only the supplied fields change.', + idempotent: true, + }, + props: { + documentationId: Property.ShortText({ displayName: 'Documentation ID', description: 'The document id from List KB Documents or a create action', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'New name', required: false }), + content: Property.LongText({ displayName: 'Content', description: 'New text content', required: false }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.PATCH, + path: `/v1/convai/knowledge-base/${encodeURIComponent(propsValue.documentationId)}`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, content: propsValue.content } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-pronunciation-dictionary.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-pronunciation-dictionary.ts new file mode 100644 index 000000000000..40a6d38cc5b8 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-pronunciation-dictionary.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsUpdatePronunciationDictionaryOutputSchema } from '../../output-schemas'; + +export const updatePronunciationDictionary = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_pronunciation_dictionary', + outputSchema: elevenlabsUpdatePronunciationDictionaryOutputSchema, + displayName: 'Update Pronunciation Dictionary', + description: 'Rename or archive a pronunciation dictionary', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Renames a pronunciation dictionary or archives it. Only the supplied fields change.', + idempotent: true, + }, + props: { + pronunciationDictionaryId: Property.ShortText({ displayName: 'Pronunciation Dictionary ID', description: 'The id from Create Pronunciation Dictionary or List Pronunciation Dictionaries', required: true }), + name: Property.ShortText({ displayName: 'Name', required: false }), + archived: Property.StaticDropdown({ displayName: 'Archived', description: 'Archive the dictionary', required: false, options: { options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] } }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.PATCH, + path: `/v1/pronunciation-dictionaries/${encodeURIComponent(propsValue.pronunciationDictionaryId)}`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, archived: propsValue.archived } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-secret.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-secret.ts new file mode 100644 index 000000000000..c8280f459b51 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-secret.ts @@ -0,0 +1,33 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateSecretOutputSchema } from '../../output-schemas'; + +export const updateSecret = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_secret', + outputSchema: elevenlabsCreateSecretOutputSchema, + displayName: 'Update Secret', + description: 'Change a workspace secret', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Replaces the name and value of a secret. Both are required. Re-applying the same values is safe.', + idempotent: true, + }, + props: { + secretId: Property.ShortText({ displayName: 'Secret ID', description: 'The secret_id from List Secrets', required: true }), + name: Property.ShortText({ displayName: 'Name', description: 'Secret name', required: true }), + value: Property.ShortText({ displayName: 'Value', description: 'Secret value', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.PATCH, + path: `/v1/convai/secrets/${encodeURIComponent(propsValue.secretId)}`, + body: elevenlabsClient.compact({ values: { name: propsValue.name, value: propsValue.value, type: 'update' } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-tool.ts b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-tool.ts new file mode 100644 index 000000000000..d0c33a9f9ebf --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/actions/ai/update-tool.ts @@ -0,0 +1,32 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { elevenlabsAuth } from '../../auth'; +import { elevenlabsClient } from '../../client'; +import { elevenlabsCreateToolOutputSchema } from '../../output-schemas'; + +export const updateTool = createAction({ + auth: elevenlabsAuth, + name: 'elevenlabs_update_tool', + outputSchema: elevenlabsCreateToolOutputSchema, + displayName: 'Update Tool', + description: 'Change an agent tool', + audience: 'ai', + classification: 'WRITE', + aiMetadata: { + description: 'Replaces the configuration of a tool. tool_config is required in full, so start from Get Tool. Re-applying the same config is safe.', + idempotent: true, + }, + props: { + toolId: Property.ShortText({ displayName: 'Tool ID', description: 'The tool id from List Tools or Create Tool', required: true }), + toolConfig: Property.Json({ displayName: 'Tool Config', description: 'Full tool configuration, from Get Tool', required: true }), + }, + async run({ auth, propsValue }) { + const response = await elevenlabsClient.request | undefined>({ + auth, + method: HttpMethod.PATCH, + path: `/v1/convai/tools/${encodeURIComponent(propsValue.toolId)}`, + body: elevenlabsClient.compact({ values: { tool_config: propsValue.toolConfig } }), + }); + return response ?? { success: true }; + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/actions/text-to-speech-action.ts b/packages/pieces/community/elevenlabs/src/lib/actions/text-to-speech-action.ts index d4981b37c6de..824aefa04acd 100644 --- a/packages/pieces/community/elevenlabs/src/lib/actions/text-to-speech-action.ts +++ b/packages/pieces/community/elevenlabs/src/lib/actions/text-to-speech-action.ts @@ -1,6 +1,7 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { createClient, ExtendedReadableStream } from '../common'; -import { elevenlabsAuth } from '../..'; +import { elevenlabsAuth } from '../auth'; +import { elevenlabsTextToSpeechOutputSchema } from '../output-schemas'; export const textToSpeech = createAction({ description: 'Convert text to speech using Elevenlabs', @@ -8,6 +9,7 @@ export const textToSpeech = createAction({ aiMetadata: { description: 'Synthesizes spoken audio from a text string using a chosen ElevenLabs voice, returning an MP3 file. Use to turn written content into a narrated voiceover or speech clip. Requires a voice (selected from the account\'s available voices) and the text; the model is optional and defaults to ElevenLabs\' default. Not idempotent — each call generates a new audio file.', idempotent: false }, displayName: 'Text to Speech', name: 'elevenlabs-text-to-speech', + outputSchema: elevenlabsTextToSpeechOutputSchema, classification: 'READ', auth: elevenlabsAuth, props: { diff --git a/packages/pieces/community/elevenlabs/src/lib/auth.ts b/packages/pieces/community/elevenlabs/src/lib/auth.ts new file mode 100644 index 000000000000..d27e5de8c795 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/auth.ts @@ -0,0 +1,52 @@ +import { PieceAuth, Property, AppConnectionType } from '@activepieces/pieces-framework'; +import { createClient, ELEVEN_RESIDENCY, ElevenResidency } from './common'; + +const markdownDescription = ` +Follow these instructions to get your API Key: +1. Visit your Elevenlabs dashboard. +2. Once there, click on your account in the bottom left corner. +3. Press Profile + API Key. +4. Create or copy your API Key. Enable the permissions for every area you plan to use: user, text to speech, speech to speech, voices, models, history, dubbing, Audio Native, pronunciation dictionaries, and Conversational AI (agents, tests, conversations, knowledge base, tools and secrets). An action whose area is not enabled on the key returns a permission error from ElevenLabs. +`; + +export const elevenlabsAuth = PieceAuth.CustomAuth({ + required: true, + description: markdownDescription, + props: { + region: Property.StaticDropdown({ + displayName: 'Region', + description: 'Use according URL in Custom API Call pieces', + required: true, + options: { + placeholder: 'Please select your account region...', + options: [ + { label: `default - ${ELEVEN_RESIDENCY['default'].base}`, value: 'default' }, + { label: `US - ${ELEVEN_RESIDENCY['us'].base}`, value: 'us' }, + { label: `EU - ${ELEVEN_RESIDENCY['eu'].base}`, value: 'eu' }, + ], + }, + }), + apiKey: PieceAuth.SecretText({ + displayName: 'API Key', + required: true, + }), + }, + validate: async ({ auth }) => { + try { + const elevenlabs = createClient({ + type: AppConnectionType.CUSTOM_AUTH, + props: auth, + }); + await elevenlabs.user.get(); + + return { + valid: true, + }; + } catch (error) { + return { + valid: false, + error: 'Invalid API Key or Region.', + }; + } + }, +}); diff --git a/packages/pieces/community/elevenlabs/src/lib/client.ts b/packages/pieces/community/elevenlabs/src/lib/client.ts new file mode 100644 index 000000000000..4e4687ea0fd4 --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/client.ts @@ -0,0 +1,117 @@ +import { httpClient, HttpError, HttpMethod } from '@activepieces/pieces-common'; +import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import FormData from 'form-data'; +import { elevenlabsAuth } from './auth'; +import { getApiKey, getRegionApiUrl } from './common'; + +type ElevenlabsAuth = AppConnectionValueForAuthProperty; + +type QueryValue = string | number | boolean | undefined | null; + +async function request({ auth, method, path, queryParams, body, formData }: RequestParams): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${getRegionApiUrl(auth.props.region)}${path}`, + headers: { + 'xi-api-key': getApiKey(auth), + ...(formData ? formData.getHeaders() : {}), + }, + queryParams: toQuery({ params: queryParams }), + body: formData ?? body, + }); + return response.body; + } catch (error) { + throw new Error(describeError({ error })); + } +} + +async function download({ auth, method, path, queryParams, body, formData }: DownloadParams): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${getRegionApiUrl(auth.props.region)}${path}`, + headers: { + 'xi-api-key': getApiKey(auth), + ...(formData ? formData.getHeaders() : {}), + }, + queryParams: toQuery({ params: queryParams }), + body: formData ?? body, + responseType: 'arraybuffer', + }); + const contentType = response.headers?.['content-type']; + return { + data: Buffer.from(response.body), + contentType: typeof contentType === 'string' ? contentType : 'application/octet-stream', + }; + } catch (error) { + throw new Error(describeError({ error })); + } +} + +function compact({ values }: { values: Record }): Record { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function toQuery({ params }: { params?: Record }): Record { + return Object.fromEntries( + Object.entries(params ?? {}) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => [key, String(value)]), + ); +} + +function describeError({ error }: { error: unknown }): string { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : 'ElevenLabs request failed'; + } + const status = error.response.status; + const detail = readDetail({ body: error.response.body }); + const hint = status === 401 || status === 403 ? ' (check the API key permissions for this endpoint)' : status === 404 ? ' (not found)' : status === 429 ? ' (rate limited)' : ''; + return `ElevenLabs API error ${status}: ${detail}${hint}`; +} + +function readDetail({ body }: { body: unknown }): string { + if (typeof body !== 'object' || body === null || !('detail' in body)) { + return typeof body === 'string' ? body : JSON.stringify(body); + } + const detail = body.detail; + if (typeof detail === 'string') { + return detail; + } + if (typeof detail === 'object' && detail !== null && 'message' in detail && typeof detail.message === 'string') { + return detail.message; + } + return JSON.stringify(detail); +} + +function extensionFor({ contentType }: { contentType: string }): string { + if (contentType.includes('zip')) return 'zip'; + if (contentType.includes('wav')) return 'wav'; + if (contentType.includes('mpeg') || contentType.includes('mp3')) return 'mp3'; + if (contentType.includes('mp4')) return 'mp4'; + if (contentType.includes('json')) return 'json'; + return 'bin'; +} + +export const elevenlabsClient = { request, download, compact, extensionFor }; + +type RequestParams = { + auth: ElevenlabsAuth; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: Record; + formData?: FormData; +}; + +type DownloadParams = { + auth: ElevenlabsAuth; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: Record; + formData?: FormData; +}; + +type DownloadedFile = { data: Buffer; contentType: string }; diff --git a/packages/pieces/community/elevenlabs/src/lib/common.ts b/packages/pieces/community/elevenlabs/src/lib/common.ts index fed724ee1961..4775472734d0 100644 --- a/packages/pieces/community/elevenlabs/src/lib/common.ts +++ b/packages/pieces/community/elevenlabs/src/lib/common.ts @@ -1,7 +1,7 @@ import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; import { ElevenLabsEnvironment } from '@elevenlabs/elevenlabs-js/environments'; import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; -import { elevenlabsAuth } from '..'; +import { elevenlabsAuth } from './auth'; export type ElevenResidency = 'default' | 'us' | 'eu'; diff --git a/packages/pieces/community/elevenlabs/src/lib/output-schemas.ts b/packages/pieces/community/elevenlabs/src/lib/output-schemas.ts new file mode 100644 index 000000000000..18776d33ef6a --- /dev/null +++ b/packages/pieces/community/elevenlabs/src/lib/output-schemas.ts @@ -0,0 +1,2875 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const elevenlabsCreateAgentOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'main_branch_id', label: 'Main Branch ID' }, + { key: 'initial_version_id', label: 'Initial Version ID' }, + ], +}; + +export const elevenlabsGetAgentOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'name', label: 'Name' }, + { + key: 'conversation_config', + label: 'Conversation Config', + children: [ + { + key: 'asr', + label: 'Asr', + children: [ + { key: 'quality', label: 'Quality' }, + { key: 'provider', label: 'Provider' }, + { key: 'user_input_audio_format', label: 'User Input Audio Format' }, + { key: 'keywords', label: 'Keywords' }, + ], + }, + { + key: 'turn', + label: 'Turn', + children: [ + { key: 'turn_timeout', label: 'Turn Timeout', format: 'number' }, + { key: 'initial_wait_time', label: 'Initial Wait Time' }, + { key: 'silence_end_call_timeout', label: 'Silence End Call Timeout', format: 'number' }, + { key: 'mode', label: 'Mode' }, + { key: 'turn_eagerness', label: 'Turn Eagerness' }, + { key: 'spelling_patience', label: 'Spelling Patience' }, + { key: 'speculative_turn', label: 'Speculative Turn', format: 'boolean' }, + { key: 'retranscribe_on_turn_timeout', label: 'Retranscribe On Turn Timeout', format: 'boolean' }, + { key: 'turn_model', label: 'Turn Model' }, + { key: 'interruption_ignore_terms', label: 'Interruption Ignore Terms' }, + { key: 'interruption_ignore_term_languages', label: 'Interruption Ignore Term Languages' }, + { + key: 'merge_with_default_ignore_terms', + label: 'Merge With Default Ignore Terms', + format: 'boolean', + }, + { + key: 'transcribe_on_disabled_interruptions', + label: 'Transcribe On Disabled Interruptions', + format: 'boolean', + }, + { + key: 'soft_timeout_config', + label: 'Soft Timeout Config', + children: [ + { key: 'timeout_seconds', label: 'Timeout Seconds', format: 'number' }, + { key: 'message', label: 'Message' }, + { key: 'additional_soft_timeout_messages', label: 'Additional Soft Timeout Messages' }, + { key: 'use_llm_generated_message', label: 'Use Llm Generated Message', format: 'boolean' }, + { key: 'randomize_fillers', label: 'Randomize Fillers', format: 'boolean' }, + { + key: 'max_soft_timeouts_per_generation', + label: 'Max Soft Timeouts Per Generation', + format: 'number', + }, + { + key: 'llm_generated_message_prompt_override', + label: 'Llm Generated Message Prompt Override', + }, + { + key: 'disable_until_first_user_message', + label: 'Disable Until First User Message', + format: 'boolean', + }, + ], + }, + ], + }, + { + key: 'tts', + label: 'Tts', + children: [ + { key: 'model_id', label: 'Model ID' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'supported_voices', label: 'Supported Voices' }, + { key: 'expressive_mode', label: 'Expressive Mode', format: 'boolean' }, + { key: 'suggested_audio_tags', label: 'Suggested Audio Tags' }, + { key: 'agent_output_audio_format', label: 'Agent Output Audio Format' }, + { key: 'optimize_streaming_latency', label: 'Optimize Streaming Latency', format: 'number' }, + { key: 'stability', label: 'Stability', format: 'number' }, + { key: 'speed', label: 'Speed', format: 'number' }, + { key: 'similarity_boost', label: 'Similarity Boost', format: 'number' }, + { key: 'text_normalisation_type', label: 'Text Normalisation Type' }, + { key: 'pronunciation_dictionary_locators', label: 'Pronunciation Dictionary Locators' }, + { key: 'enable_phoneme_tags', label: 'Enable Phoneme Tags', format: 'boolean' }, + { key: 'audio_effects', label: 'Audio Effects' }, + ], + }, + { + key: 'conversation', + label: 'Conversation', + children: [ + { key: 'text_only', label: 'Text Only', format: 'boolean' }, + { key: 'max_duration_seconds', label: 'Max Duration Seconds', format: 'number' }, + { + key: 'max_duration_seconds_after_last_agent_message', + label: 'Max Duration Seconds After Last Agent Message', + }, + { key: 'client_events', label: 'Client Events' }, + { + key: 'file_input', + label: 'File Input', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'max_files_in_memory', label: 'Max Files In Memory', format: 'number' }, + { key: 'max_files_per_conversation', label: 'Max Files Per Conversation', format: 'number' }, + ], + }, + { key: 'monitoring_enabled', label: 'Monitoring Enabled', format: 'boolean' }, + { key: 'monitoring_events', label: 'Monitoring Events' }, + { key: 'dtmf_input_settings', label: 'Dtmf Input Settings' }, + { + key: 'background_sound', + label: 'Background Sound', + children: [ + { key: 'source_type', label: 'Source Type' }, + { key: 'source_id', label: 'Source ID' }, + { key: 'volume', label: 'Volume', format: 'number' }, + { key: 'crossfade_loop', label: 'Crossfade Loop', format: 'boolean' }, + ], + }, + { + key: 'compaction', + label: 'Compaction', + children: [ + { key: 'enabled', label: 'Enabled' }, + { key: 'soft_trigger_fraction', label: 'Soft Trigger Fraction' }, + { key: 'tail_size', label: 'Tail Size' }, + { key: 'min_reclaimable_tokens', label: 'Min Reclaimable Tokens' }, + ], + }, + { key: 'source_attribution', label: 'Source Attribution', format: 'boolean' }, + ], + }, + { key: 'language_presets', label: 'Language Presets' }, + { + key: 'vad', + label: 'Vad', + children: [ + { key: 'background_voice_detection', label: 'Background Voice Detection', format: 'boolean' }, + ], + }, + { key: 'realtime_model', label: 'Realtime Model' }, + { + key: 'agent', + label: 'Agent', + children: [ + { key: 'first_message', label: 'First Message' }, + { key: 'language', label: 'Language' }, + { key: 'hinglish_mode', label: 'Hinglish Mode', format: 'boolean' }, + { + key: 'dynamic_variables', + label: 'Dynamic Variables', + children: [ + { key: 'dynamic_variable_placeholders', label: 'Dynamic Variable Placeholders' }, + ], + }, + { + key: 'disable_first_message_interruptions', + label: 'Disable First Message Interruptions', + format: 'boolean', + }, + { key: 'max_conversation_duration_message', label: 'Max Conversation Duration Message' }, + { key: 'text_behavior_overrides', label: 'Text Behavior Overrides' }, + { key: 'subagents', label: 'Subagents' }, + { + key: 'prompt', + label: 'Prompt', + children: [ + { key: 'prompt', label: 'Prompt' }, + { key: 'llm', label: 'Llm' }, + { key: 'reasoning_effort', label: 'Reasoning Effort' }, + { key: 'service_tier', label: 'Service Tier' }, + { key: 'opener', label: 'Opener' }, + { key: 'thinking_budget', label: 'Thinking Budget' }, + { key: 'enable_reasoning_summary', label: 'Enable Reasoning Summary', format: 'boolean' }, + { key: 'temperature', label: 'Temperature', format: 'number' }, + { key: 'max_tokens', label: 'Max Tokens', format: 'number' }, + { key: 'tool_ids', label: 'Tool IDs' }, + { + key: 'built_in_tools', + label: 'Built In Tools', + children: [ + { key: 'transfer_to_agent', label: 'Transfer To Agent' }, + { key: 'update_state', label: 'Update State' }, + { key: 'memory_entry_search', label: 'Memory Entry Search' }, + { key: 'run_subagent', label: 'Run Subagent' }, + { key: 'flag_issue_for_review', label: 'Flag Issue For Review' }, + { key: 'end_call', label: 'End Call' }, + { key: 'language_detection', label: 'Language Detection' }, + { key: 'transfer_to_number', label: 'Transfer To Number' }, + { key: 'skip_turn', label: 'Skip Turn' }, + { key: 'play_keypad_touch_tone', label: 'Play Keypad Touch Tone' }, + { key: 'voicemail_detection', label: 'Voicemail Detection' }, + { key: 'transfer_to_genesys', label: 'Transfer To Genesys' }, + { key: 'transfer_to_genesys_chat', label: 'Transfer To Genesys Chat' }, + { key: 'transfer_to_genesys_bot', label: 'Transfer To Genesys Bot' }, + ], + }, + { key: 'enable_parallel_tool_calls', label: 'Enable Parallel Tool Calls', format: 'boolean' }, + { key: 'mcp_server_ids', label: 'Mcp Server IDs' }, + { key: 'native_mcp_server_ids', label: 'Native Mcp Server IDs' }, + { key: 'knowledge_base', label: 'Knowledge Base' }, + { key: 'custom_llm', label: 'Custom Llm' }, + { key: 'bedrock_llm', label: 'Bedrock Llm' }, + { key: 'speech_engine', label: 'Speech Engine' }, + { key: 'ignore_default_personality', label: 'Ignore Default Personality', format: 'boolean' }, + { + key: 'rag', + label: 'Rag', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'embedding_model', label: 'Embedding Model' }, + { key: 'optional_rag_enabled', label: 'Optional Rag Enabled', format: 'boolean' }, + { key: 'max_vector_distance', label: 'Max Vector Distance', format: 'number' }, + { key: 'max_documents_length', label: 'Max Documents Length', format: 'number' }, + { + key: 'max_retrieved_rag_chunks_count', + label: 'Max Retrieved Rag Chunks Count', + format: 'number', + }, + { key: 'num_candidates', label: 'Num Candidates' }, + { key: 'query_rewrite_prompt_override', label: 'Query Rewrite Prompt Override' }, + { key: 'knowledge_base_tool_info', label: 'Knowledge Base Tool Info' }, + { key: 'include_source_urls', label: 'Include Source Urls', format: 'boolean' }, + ], + }, + { key: 'timezone', label: 'Timezone' }, + { + key: 'backup_llm_config', + label: 'Backup Llm Config', + children: [ + { key: 'preference', label: 'Preference' }, + ], + }, + { key: 'cascade_timeout_seconds', label: 'Cascade Timeout Seconds', format: 'number' }, + { key: 'tools', label: 'Tools' }, + ], + }, + ], + }, + ], + }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'updated_at_unix_secs', label: 'Updated At Unix Secs', format: 'number' }, + ], + }, + { + key: 'platform_settings', + label: 'Platform Settings', + children: [ + { + key: 'evaluation', + label: 'Evaluation', + children: [ + { key: 'criteria', label: 'Criteria' }, + ], + }, + { + key: 'widget', + label: 'Widget', + children: [ + { key: 'variant', label: 'Variant' }, + { key: 'placement', label: 'Placement' }, + { key: 'expandable', label: 'Expandable' }, + { + key: 'avatar', + label: 'Avatar', + children: [ + { key: 'type', label: 'Type' }, + { key: 'color_1', label: 'Color 1' }, + { key: 'color_2', label: 'Color 2' }, + ], + }, + { key: 'feedback_mode', label: 'Feedback Mode' }, + { + key: 'end_feedback', + label: 'End Feedback', + children: [ + { key: 'type', label: 'Type' }, + ], + }, + { key: 'bg_color', label: 'Bg Color' }, + { key: 'text_color', label: 'Text Color' }, + { key: 'btn_color', label: 'Btn Color' }, + { key: 'btn_text_color', label: 'Btn Text Color' }, + { key: 'border_color', label: 'Border Color' }, + { key: 'focus_color', label: 'Focus Color' }, + { key: 'border_radius', label: 'Border Radius' }, + { key: 'btn_radius', label: 'Btn Radius' }, + { key: 'action_text', label: 'Action Text' }, + { key: 'start_call_text', label: 'Start Call Text' }, + { key: 'end_call_text', label: 'End Call Text' }, + { key: 'expand_text', label: 'Expand Text' }, + { key: 'listening_text', label: 'Listening Text' }, + { key: 'speaking_text', label: 'Speaking Text' }, + { key: 'shareable_page_text', label: 'Shareable Page Text' }, + { key: 'shareable_page_show_terms', label: 'Shareable Page Show Terms', format: 'boolean' }, + { key: 'terms_text', label: 'Terms Text' }, + { key: 'terms_html', label: 'Terms HTML' }, + { key: 'terms_key', label: 'Terms Key' }, + { key: 'show_avatar_when_collapsed', label: 'Show Avatar When Collapsed', format: 'boolean' }, + { key: 'disable_banner', label: 'Disable Banner', format: 'boolean' }, + { key: 'override_link', label: 'Override Link' }, + { key: 'markdown_link_allowed_hosts', label: 'Markdown Link Allowed Hosts' }, + { key: 'markdown_link_include_www', label: 'Markdown Link Include Www', format: 'boolean' }, + { key: 'markdown_link_allow_http', label: 'Markdown Link Allow Http', format: 'boolean' }, + { key: 'mic_muting_enabled', label: 'Mic Muting Enabled', format: 'boolean' }, + { key: 'transcript_enabled', label: 'Transcript Enabled', format: 'boolean' }, + { key: 'text_input_enabled', label: 'Text Input Enabled', format: 'boolean' }, + { + key: 'conversation_mode_toggle_enabled', + label: 'Conversation Mode Toggle Enabled', + format: 'boolean', + }, + { key: 'default_expanded', label: 'Default Expanded', format: 'boolean' }, + { key: 'always_expanded', label: 'Always Expanded', format: 'boolean' }, + { key: 'dismissible', label: 'Dismissible', format: 'boolean' }, + { key: 'show_agent_status', label: 'Show Agent Status', format: 'boolean' }, + { key: 'show_conversation_id', label: 'Show Conversation ID', format: 'boolean' }, + { key: 'strip_audio_tags', label: 'Strip Audio Tags', format: 'boolean' }, + { key: 'syntax_highlight_theme', label: 'Syntax Highlight Theme' }, + { + key: 'text_contents', + label: 'Text Contents', + children: [ + { key: 'main_label', label: 'Main Label' }, + { key: 'start_call', label: 'Start Call' }, + { key: 'start_chat', label: 'Start Chat' }, + { key: 'new_call', label: 'New Call' }, + { key: 'end_call', label: 'End Call' }, + { key: 'mute_microphone', label: 'Mute Microphone' }, + { key: 'change_language', label: 'Change Language' }, + { key: 'collapse', label: 'Collapse' }, + { key: 'expand', label: 'Expand' }, + { key: 'copied', label: 'Copied' }, + { key: 'accept_terms', label: 'Accept Terms' }, + { key: 'dismiss_terms', label: 'Dismiss Terms' }, + { key: 'listening_status', label: 'Listening Status' }, + { key: 'speaking_status', label: 'Speaking Status' }, + { key: 'connecting_status', label: 'Connecting Status' }, + { key: 'chatting_status', label: 'Chatting Status' }, + { key: 'queue_waiting_status', label: 'Queue Waiting Status' }, + { key: 'queue_waiting_status_short', label: 'Queue Waiting Status Short' }, + { key: 'input_label', label: 'Input Label' }, + { key: 'input_placeholder', label: 'Input Placeholder' }, + { key: 'input_placeholder_text_only', label: 'Input Placeholder Text Only' }, + { key: 'input_placeholder_new_conversation', label: 'Input Placeholder New Conversation' }, + { key: 'user_ended_conversation', label: 'User Ended Conversation' }, + { key: 'agent_ended_conversation', label: 'Agent Ended Conversation' }, + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'error_occurred', label: 'Error Occurred' }, + { key: 'queue_timed_out', label: 'Queue Timed Out' }, + { key: 'copy_id', label: 'Copy ID' }, + { key: 'initiate_feedback', label: 'Initiate Feedback' }, + { key: 'request_follow_up_feedback', label: 'Request Follow Up Feedback' }, + { key: 'thanks_for_feedback', label: 'Thanks For Feedback' }, + { key: 'thanks_for_feedback_details', label: 'Thanks For Feedback Details' }, + { key: 'follow_up_feedback_placeholder', label: 'Follow Up Feedback Placeholder' }, + { key: 'submit', label: 'Submit' }, + { key: 'go_back', label: 'Go Back' }, + { key: 'send_message', label: 'Send Message' }, + { key: 'text_mode', label: 'Text Mode' }, + { key: 'voice_mode', label: 'Voice Mode' }, + { key: 'switched_to_text_mode', label: 'Switched To Text Mode' }, + { key: 'switched_to_voice_mode', label: 'Switched To Voice Mode' }, + { key: 'copy', label: 'Copy' }, + { key: 'download', label: 'Download' }, + { key: 'wrap', label: 'Wrap' }, + { key: 'agent_working', label: 'Agent Working' }, + { key: 'agent_done', label: 'Agent Done' }, + { key: 'agent_error', label: 'Agent Error' }, + { key: 'attach_file', label: 'Attach File' }, + { key: 'remove_file', label: 'Remove File' }, + { key: 'file_upload_error', label: 'File Upload Error' }, + { key: 'file_type_unsupported', label: 'File Type Unsupported' }, + { key: 'file_too_large', label: 'File Too Large' }, + { key: 'file_limit_reached', label: 'File Limit Reached' }, + { key: 'typing_indicator', label: 'Typing Indicator' }, + { key: 'rich_content_unavailable', label: 'Rich Content Unavailable' }, + ], + }, + { + key: 'styles', + label: 'Styles', + children: [ + { key: 'base', label: 'Base' }, + { key: 'base_hover', label: 'Base Hover' }, + { key: 'base_active', label: 'Base Active' }, + { key: 'base_border', label: 'Base Border' }, + { key: 'base_subtle', label: 'Base Subtle' }, + { key: 'base_primary', label: 'Base Primary' }, + { key: 'base_error', label: 'Base Error' }, + { key: 'accent', label: 'Accent' }, + { key: 'accent_hover', label: 'Accent Hover' }, + { key: 'accent_active', label: 'Accent Active' }, + { key: 'accent_border', label: 'Accent Border' }, + { key: 'accent_subtle', label: 'Accent Subtle' }, + { key: 'accent_primary', label: 'Accent Primary' }, + { key: 'overlay_padding', label: 'Overlay Padding' }, + { key: 'button_radius', label: 'Button Radius' }, + { key: 'input_radius', label: 'Input Radius' }, + { key: 'bubble_radius', label: 'Bubble Radius' }, + { key: 'sheet_radius', label: 'Sheet Radius' }, + { key: 'compact_sheet_radius', label: 'Compact Sheet Radius' }, + { key: 'dropdown_sheet_radius', label: 'Dropdown Sheet Radius' }, + ], + }, + { key: 'show_resize_button', label: 'Show Resize Button', format: 'boolean' }, + { key: 'language_selector', label: 'Language Selector', format: 'boolean' }, + { key: 'supports_text_only', label: 'Supports Text Only', format: 'boolean' }, + { key: 'custom_avatar_path', label: 'Custom Avatar Path' }, + { key: 'language_presets', label: 'Language Presets' }, + { key: 'first_message_quick_replies', label: 'First Message Quick Replies' }, + ], + }, + { key: 'data_collection', label: 'Data Collection' }, + { key: 'data_collection_scopes', label: 'Data Collection Scopes' }, + { + key: 'analysis_items', + label: 'Analysis Items', + children: [ + { key: 'evaluation_criteria', label: 'Evaluation Criteria' }, + { key: 'data_collection', label: 'Data Collection' }, + ], + }, + { + key: 'overrides', + label: 'Overrides', + children: [ + { + key: 'conversation_config_override', + label: 'Conversation Config Override', + children: [ + { + key: 'asr', + label: 'Asr', + children: [ + { key: 'keywords', label: 'Keywords', format: 'boolean' }, + ], + }, + { + key: 'turn', + label: 'Turn', + children: [ + { key: 'soft_timeout_config', label: 'Soft Timeout Config' }, + ], + }, + { + key: 'tts', + label: 'Tts', + children: [ + { key: 'model_id', label: 'Model ID', format: 'boolean' }, + { key: 'voice_id', label: 'Voice ID', format: 'boolean' }, + { key: 'supported_voices', label: 'Supported Voices', format: 'boolean' }, + { key: 'stability', label: 'Stability', format: 'boolean' }, + { key: 'speed', label: 'Speed', format: 'boolean' }, + { key: 'similarity_boost', label: 'Similarity Boost', format: 'boolean' }, + { + key: 'pronunciation_dictionary_locators', + label: 'Pronunciation Dictionary Locators', + format: 'boolean', + }, + ], + }, + { + key: 'conversation', + label: 'Conversation', + children: [ + { key: 'text_only', label: 'Text Only', format: 'boolean' }, + { key: 'max_duration_seconds', label: 'Max Duration Seconds', format: 'boolean' }, + ], + }, + { + key: 'agent', + label: 'Agent', + children: [ + { key: 'first_message', label: 'First Message', format: 'boolean' }, + { key: 'language', label: 'Language', format: 'boolean' }, + { + key: 'max_conversation_duration_message', + label: 'Max Conversation Duration Message', + format: 'boolean', + }, + { key: 'prompt', label: 'Prompt' }, + ], + }, + ], + }, + { key: 'custom_llm_extra_body', label: 'Custom Llm Extra Body', format: 'boolean' }, + { + key: 'enable_conversation_initiation_client_data_from_webhook', + label: 'Enable Conversation Initiation Client Data From Webhook', + format: 'boolean', + }, + { + key: 'enable_starting_workflow_node_id_from_client', + label: 'Enable Starting Workflow Node ID From Client', + format: 'boolean', + }, + { + key: 'enable_procedure_ids_from_client', + label: 'Enable Procedure IDs From Client', + format: 'boolean', + }, + ], + }, + { + key: 'workspace_overrides', + label: 'Workspace Overrides', + children: [ + { + key: 'conversation_initiation_client_data_webhook', + label: 'Conversation Initiation Client Data Webhook', + }, + { + key: 'webhooks', + label: 'Webhooks', + children: [ + { key: 'post_call_webhook_id', label: 'Post Call Webhook ID' }, + { key: 'events', label: 'Events' }, + { key: 'transcript_format', label: 'Transcript Format' }, + { key: 'send_audio', label: 'Send Audio', format: 'boolean' }, + ], + }, + { + key: 'cloud_storage_export', + label: 'Cloud Storage Export', + children: [ + { key: 'destination_id', label: 'Destination ID' }, + { key: 'events', label: 'Events' }, + { key: 'transcript_format', label: 'Transcript Format' }, + ], + }, + ], + }, + { + key: 'testing', + label: 'Testing', + children: [ + { key: 'attached_tests', label: 'Attached Tests' }, + { key: 'referenced_tests_ids', label: 'Referenced Tests IDs' }, + ], + }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { + key: 'guardrails', + label: 'Guardrails', + children: [ + { key: 'version', label: 'Version' }, + { + key: 'focus', + label: 'Focus', + children: [ + { key: 'is_enabled', label: 'Is Enabled', format: 'boolean' }, + ], + }, + { + key: 'prompt_injection', + label: 'Prompt Injection', + children: [ + { key: 'is_enabled', label: 'Is Enabled', format: 'boolean' }, + ], + }, + { + key: 'synthetic_voice', + label: 'Synthetic Voice', + children: [ + { key: 'is_enabled', label: 'Is Enabled', format: 'boolean' }, + { + key: 'trigger_action', + label: 'Trigger Action', + children: [ + { key: 'type', label: 'Type' }, + ], + }, + ], + }, + { + key: 'content', + label: 'Content', + children: [ + { key: 'execution_mode', label: 'Execution Mode' }, + { + key: 'trigger_action', + label: 'Trigger Action', + children: [ + { key: 'type', label: 'Type' }, + ], + }, + ], + }, + { key: 'moderation', label: 'Moderation' }, + { key: 'custom', label: 'Custom' }, + ], + }, + { key: 'summary_language', label: 'Summary Language' }, + { + key: 'auto_translate_transcript_to_app_language', + label: 'Auto Translate Transcript To App Language', + }, + { + key: 'auth', + label: 'Auth', + children: [ + { key: 'enable_auth', label: 'Enable Auth', format: 'boolean' }, + { key: 'allowlist', label: 'Allowlist' }, + { key: 'require_origin_header', label: 'Require Origin Header', format: 'boolean' }, + { key: 'shareable_token', label: 'Shareable Token' }, + ], + }, + { + key: 'call_limits', + label: 'Call Limits', + children: [ + { key: 'agent_concurrency_limit', label: 'Agent Concurrency Limit', format: 'number' }, + { key: 'daily_limit', label: 'Daily Limit', format: 'number' }, + { key: 'bursting_enabled', label: 'Bursting Enabled', format: 'boolean' }, + ], + }, + { + key: 'queueing_config', + label: 'Queueing Config', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'wait_timeout_seconds', label: 'Wait Timeout Seconds', format: 'number' }, + { key: 'hold_audio', label: 'Hold Audio' }, + ], + }, + { key: 'ban', label: 'Ban' }, + { key: 'smb_metadata', label: 'Smb Metadata' }, + { + key: 'privacy', + label: 'Privacy', + children: [ + { key: 'record_voice', label: 'Record Voice', format: 'boolean' }, + { key: 'retention_days', label: 'Retention Days', format: 'number' }, + { key: 'delete_transcript_and_pii', label: 'Delete Transcript And Pii', format: 'boolean' }, + { key: 'delete_audio', label: 'Delete Audio', format: 'boolean' }, + { + key: 'apply_to_existing_conversations', + label: 'Apply To Existing Conversations', + format: 'boolean', + }, + { key: 'zero_retention_mode', label: 'Zero Retention Mode', format: 'boolean' }, + { key: 'nested_history_redaction', label: 'Nested History Redaction', format: 'boolean' }, + { + key: 'conversation_history_redaction', + label: 'Conversation History Redaction', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'entities', label: 'Entities' }, + ], + }, + { + key: 'user_memory', + label: 'User Memory', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'scale', label: 'Scale' }, + ], + }, + ], + }, + { key: 'trust_context', label: 'Trust Context' }, + { key: 'analysis_llm', label: 'Analysis Llm' }, + { key: 'analysis_bedrock_llm', label: 'Analysis Bedrock Llm' }, + { key: 'analysis_llm_billed', label: 'Analysis Llm Billed', format: 'boolean' }, + { + key: 'topic_discovery', + label: 'Topic Discovery', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + ], + }, + { + key: 'sentiment_analysis', + label: 'Sentiment Analysis', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + ], + }, + { + key: 'simulation_library', + label: 'Simulation Library', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'test_ids', label: 'Test IDs' }, + ], + }, + { key: 'alerting', label: 'Alerting' }, + { key: 'overview_dashboard_template_id', label: 'Overview Dashboard Template ID' }, + { + key: 'safety', + label: 'Safety', + children: [ + { key: 'is_blocked_ivc', label: 'Is Blocked Ivc', format: 'boolean' }, + { key: 'is_blocked_non_ivc', label: 'Is Blocked Non Ivc', format: 'boolean' }, + { key: 'ignore_safety_evaluation', label: 'Ignore Safety Evaluation', format: 'boolean' }, + ], + }, + ], + }, + { key: 'phone_numbers', label: 'Phone Numbers' }, + { key: 'whatsapp_accounts', label: 'Whatsapp Accounts' }, + { + key: 'workflow', + label: 'Workflow', + children: [ + { key: 'edges', label: 'Edges' }, + { + key: 'nodes', + label: 'Nodes', + children: [ + { + key: 'start_node', + label: 'Start Node', + children: [ + { key: 'type', label: 'Type' }, + { + key: 'position', + label: 'Position', + children: [ + { key: 'x', label: 'X', format: 'number' }, + { key: 'y', label: 'Y', format: 'number' }, + ], + }, + { key: 'edge_order', label: 'Edge Order' }, + { key: 'parent_subgraph_id', label: 'Parent Subgraph ID' }, + ], + }, + ], + }, + { key: 'subgraphs', label: 'Subgraphs' }, + { key: 'prevent_subagent_loops', label: 'Prevent Subagent Loops', format: 'boolean' }, + ], + }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'access_permissions', label: 'Access Permissions' }, + { key: 'tags', label: 'Tags' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'main_branch_id', label: 'Main Branch ID' }, + { key: 'procedures', label: 'Procedures' }, + { key: 'trust_context', label: 'Trust Context' }, + { key: 'default_hold_audio_url', label: 'Default Hold Audio URL', format: 'url' }, + ], +}; + +export const elevenlabsGetAgentKnowledgeBaseSizeOutputSchema: OutputSchema = { + fields: [ + { key: 'number_of_pages', label: 'Number Of Pages', format: 'number' }, + ], +}; + +export const elevenlabsGetAgentLinkOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'token', label: 'Token' }, + ], +}; + +export const elevenlabsListAgentsOutputSchema: OutputSchema = { + fields: [ + { + key: 'agents', + label: 'Agents', + labelKey: 'name', + listItems: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'name', label: 'Name' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'tags', label: 'Tags' }, + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'last_call_time_unix_secs', label: 'Last Call Time Unix Secs' }, + { key: 'archived', label: 'Archived', format: 'boolean' }, + { key: 'last_7_day_call_count', label: 'Last 7 Day Call Count', format: 'number' }, + { key: 'trust_context', label: 'Trust Context' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsCalculateAgentLlmUsageOutputSchema: OutputSchema = { + fields: [ + { + key: 'llm_prices', + label: 'Llm Prices', + listItems: [ + { key: 'llm', label: 'Llm' }, + { key: 'price_per_minute', label: 'Price Per Minute', format: 'number' }, + { key: 'price_per_message', label: 'Price Per Message', format: 'number' }, + ], + }, + ], +}; + +export const elevenlabsGetAgentWidgetOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { + key: 'widget_config', + label: 'Widget Config', + children: [ + { key: 'variant', label: 'Variant' }, + { key: 'placement', label: 'Placement' }, + { key: 'expandable', label: 'Expandable' }, + { + key: 'avatar', + label: 'Avatar', + children: [ + { key: 'type', label: 'Type' }, + { key: 'color_1', label: 'Color 1' }, + { key: 'color_2', label: 'Color 2' }, + ], + }, + { key: 'feedback_mode', label: 'Feedback Mode' }, + { + key: 'end_feedback', + label: 'End Feedback', + children: [ + { key: 'type', label: 'Type' }, + ], + }, + { key: 'bg_color', label: 'Bg Color' }, + { key: 'text_color', label: 'Text Color' }, + { key: 'btn_color', label: 'Btn Color' }, + { key: 'btn_text_color', label: 'Btn Text Color' }, + { key: 'border_color', label: 'Border Color' }, + { key: 'focus_color', label: 'Focus Color' }, + { key: 'border_radius', label: 'Border Radius' }, + { key: 'btn_radius', label: 'Btn Radius' }, + { key: 'action_text', label: 'Action Text' }, + { key: 'start_call_text', label: 'Start Call Text' }, + { key: 'end_call_text', label: 'End Call Text' }, + { key: 'expand_text', label: 'Expand Text' }, + { key: 'listening_text', label: 'Listening Text' }, + { key: 'speaking_text', label: 'Speaking Text' }, + { key: 'shareable_page_text', label: 'Shareable Page Text' }, + { key: 'shareable_page_show_terms', label: 'Shareable Page Show Terms', format: 'boolean' }, + { key: 'terms_text', label: 'Terms Text' }, + { key: 'terms_html', label: 'Terms HTML' }, + { key: 'terms_key', label: 'Terms Key' }, + { key: 'show_avatar_when_collapsed', label: 'Show Avatar When Collapsed', format: 'boolean' }, + { key: 'disable_banner', label: 'Disable Banner', format: 'boolean' }, + { key: 'override_link', label: 'Override Link' }, + { key: 'markdown_link_allowed_hosts', label: 'Markdown Link Allowed Hosts' }, + { key: 'markdown_link_include_www', label: 'Markdown Link Include Www', format: 'boolean' }, + { key: 'markdown_link_allow_http', label: 'Markdown Link Allow Http', format: 'boolean' }, + { key: 'mic_muting_enabled', label: 'Mic Muting Enabled', format: 'boolean' }, + { key: 'transcript_enabled', label: 'Transcript Enabled', format: 'boolean' }, + { key: 'text_input_enabled', label: 'Text Input Enabled', format: 'boolean' }, + { + key: 'conversation_mode_toggle_enabled', + label: 'Conversation Mode Toggle Enabled', + format: 'boolean', + }, + { key: 'default_expanded', label: 'Default Expanded', format: 'boolean' }, + { key: 'always_expanded', label: 'Always Expanded', format: 'boolean' }, + { key: 'dismissible', label: 'Dismissible', format: 'boolean' }, + { key: 'show_agent_status', label: 'Show Agent Status', format: 'boolean' }, + { key: 'show_conversation_id', label: 'Show Conversation ID', format: 'boolean' }, + { key: 'strip_audio_tags', label: 'Strip Audio Tags', format: 'boolean' }, + { key: 'syntax_highlight_theme', label: 'Syntax Highlight Theme' }, + { + key: 'text_contents', + label: 'Text Contents', + children: [ + { key: 'main_label', label: 'Main Label' }, + { key: 'start_call', label: 'Start Call' }, + { key: 'start_chat', label: 'Start Chat' }, + { key: 'new_call', label: 'New Call' }, + { key: 'end_call', label: 'End Call' }, + { key: 'mute_microphone', label: 'Mute Microphone' }, + { key: 'change_language', label: 'Change Language' }, + { key: 'collapse', label: 'Collapse' }, + { key: 'expand', label: 'Expand' }, + { key: 'copied', label: 'Copied' }, + { key: 'accept_terms', label: 'Accept Terms' }, + { key: 'dismiss_terms', label: 'Dismiss Terms' }, + { key: 'listening_status', label: 'Listening Status' }, + { key: 'speaking_status', label: 'Speaking Status' }, + { key: 'connecting_status', label: 'Connecting Status' }, + { key: 'chatting_status', label: 'Chatting Status' }, + { key: 'queue_waiting_status', label: 'Queue Waiting Status' }, + { key: 'queue_waiting_status_short', label: 'Queue Waiting Status Short' }, + { key: 'input_label', label: 'Input Label' }, + { key: 'input_placeholder', label: 'Input Placeholder' }, + { key: 'input_placeholder_text_only', label: 'Input Placeholder Text Only' }, + { key: 'input_placeholder_new_conversation', label: 'Input Placeholder New Conversation' }, + { key: 'user_ended_conversation', label: 'User Ended Conversation' }, + { key: 'agent_ended_conversation', label: 'Agent Ended Conversation' }, + { key: 'conversation_id', label: 'Conversation ID' }, + { key: 'error_occurred', label: 'Error Occurred' }, + { key: 'queue_timed_out', label: 'Queue Timed Out' }, + { key: 'copy_id', label: 'Copy ID' }, + { key: 'initiate_feedback', label: 'Initiate Feedback' }, + { key: 'request_follow_up_feedback', label: 'Request Follow Up Feedback' }, + { key: 'thanks_for_feedback', label: 'Thanks For Feedback' }, + { key: 'thanks_for_feedback_details', label: 'Thanks For Feedback Details' }, + { key: 'follow_up_feedback_placeholder', label: 'Follow Up Feedback Placeholder' }, + { key: 'submit', label: 'Submit' }, + { key: 'go_back', label: 'Go Back' }, + { key: 'send_message', label: 'Send Message' }, + { key: 'text_mode', label: 'Text Mode' }, + { key: 'voice_mode', label: 'Voice Mode' }, + { key: 'switched_to_text_mode', label: 'Switched To Text Mode' }, + { key: 'switched_to_voice_mode', label: 'Switched To Voice Mode' }, + { key: 'copy', label: 'Copy' }, + { key: 'download', label: 'Download' }, + { key: 'wrap', label: 'Wrap' }, + { key: 'agent_working', label: 'Agent Working' }, + { key: 'agent_done', label: 'Agent Done' }, + { key: 'agent_error', label: 'Agent Error' }, + { key: 'attach_file', label: 'Attach File' }, + { key: 'remove_file', label: 'Remove File' }, + { key: 'file_upload_error', label: 'File Upload Error' }, + { key: 'file_type_unsupported', label: 'File Type Unsupported' }, + { key: 'file_too_large', label: 'File Too Large' }, + { key: 'file_limit_reached', label: 'File Limit Reached' }, + { key: 'typing_indicator', label: 'Typing Indicator' }, + { key: 'rich_content_unavailable', label: 'Rich Content Unavailable' }, + ], + }, + { + key: 'styles', + label: 'Styles', + children: [ + { key: 'base', label: 'Base' }, + { key: 'base_hover', label: 'Base Hover' }, + { key: 'base_active', label: 'Base Active' }, + { key: 'base_border', label: 'Base Border' }, + { key: 'base_subtle', label: 'Base Subtle' }, + { key: 'base_primary', label: 'Base Primary' }, + { key: 'base_error', label: 'Base Error' }, + { key: 'accent', label: 'Accent' }, + { key: 'accent_hover', label: 'Accent Hover' }, + { key: 'accent_active', label: 'Accent Active' }, + { key: 'accent_border', label: 'Accent Border' }, + { key: 'accent_subtle', label: 'Accent Subtle' }, + { key: 'accent_primary', label: 'Accent Primary' }, + { key: 'overlay_padding', label: 'Overlay Padding' }, + { key: 'button_radius', label: 'Button Radius' }, + { key: 'input_radius', label: 'Input Radius' }, + { key: 'bubble_radius', label: 'Bubble Radius' }, + { key: 'sheet_radius', label: 'Sheet Radius' }, + { key: 'compact_sheet_radius', label: 'Compact Sheet Radius' }, + { key: 'dropdown_sheet_radius', label: 'Dropdown Sheet Radius' }, + ], + }, + { key: 'show_resize_button', label: 'Show Resize Button', format: 'boolean' }, + { key: 'language', label: 'Language' }, + { key: 'supported_language_overrides', label: 'Supported Language Overrides' }, + { key: 'language_presets', label: 'Language Presets' }, + { key: 'text_only', label: 'Text Only', format: 'boolean' }, + { key: 'supports_text_only', label: 'Supports Text Only', format: 'boolean' }, + { key: 'first_message', label: 'First Message' }, + { key: 'use_rtc', label: 'Use Rtc' }, + { + key: 'file_input_config', + label: 'File Input Config', + children: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'max_files_in_memory', label: 'Max Files In Memory', format: 'number' }, + { key: 'max_files_per_conversation', label: 'Max Files Per Conversation', format: 'number' }, + ], + }, + { key: 'first_message_rich_content', label: 'First Message Rich Content' }, + ], + }, + ], +}; + +export const elevenlabsCreateAudioNativeProjectOutputSchema: OutputSchema = { + fields: [ + { key: 'project_id', label: 'Project ID' }, + { key: 'converting', label: 'Converting', format: 'boolean' }, + { key: 'html_snippet', label: 'HTML Snippet' }, + ], +}; + +export const elevenlabsGetAudioNativeSettingsOutputSchema: OutputSchema = { + fields: [ + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'snapshot_id', label: 'Snapshot ID' }, + { + key: 'settings', + label: 'Settings', + children: [ + { key: 'title', label: 'Title' }, + { key: 'image', label: 'Image' }, + { key: 'author', label: 'Author' }, + { key: 'small', label: 'Small', format: 'boolean' }, + { key: 'text_color', label: 'Text Color' }, + { key: 'background_color', label: 'Background Color' }, + { key: 'sessionization', label: 'Sessionization', format: 'number' }, + { key: 'audio_path', label: 'Audio Path' }, + { key: 'audio_url', label: 'Audio URL' }, + { key: 'status', label: 'Status' }, + ], + }, + ], +}; + +export const elevenlabsUpdateAudioNativeContentOutputSchema: OutputSchema = { + fields: [ + { key: 'project_id', label: 'Project ID' }, + { key: 'converting', label: 'Converting', format: 'boolean' }, + { key: 'publishing', label: 'Publishing', format: 'boolean' }, + { key: 'html_snippet', label: 'HTML Snippet' }, + ], +}; + +export const elevenlabsSetAgentAvatarOutputSchema: OutputSchema = { + fields: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'avatar_url', label: 'Avatar URL', format: 'image' }, + ], +}; + +export const elevenlabsListBatchCallsOutputSchema: OutputSchema = { + fields: [ + { key: 'batch_calls', label: 'Batch Calls' }, + { key: 'next_doc', label: 'Next Doc' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsListConversationsOutputSchema: OutputSchema = { + fields: [ + { key: 'conversations', label: 'Conversations' }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsGetDefaultVoiceSettingsOutputSchema: OutputSchema = { + fields: [ + { key: 'stability', label: 'Stability', format: 'number' }, + { key: 'use_speaker_boost', label: 'Use Speaker Boost', format: 'boolean' }, + { key: 'similarity_boost', label: 'Similarity Boost', format: 'number' }, + { key: 'style', label: 'Style', format: 'number' }, + { key: 'speed', label: 'Speed', format: 'number' }, + ], +}; + +export const elevenlabsCreateDubbingOutputSchema: OutputSchema = { + fields: [ + { key: 'dubbing_id', label: 'Dubbing ID' }, + { key: 'expected_duration_sec', label: 'Expected Duration Sec', format: 'number' }, + ], +}; + +export const elevenlabsDeleteDubbingOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + ], +}; + +export const elevenlabsGetDubbedFileOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'File', format: 'url' }, + { key: 'content_type', label: 'Content Type' }, + ], +}; + +export const elevenlabsGetDubbingTranscriptOutputSchema: OutputSchema = { + fields: [ + { key: 'language', label: 'Language' }, + { + key: 'utterances', + label: 'Utterances', + listItems: [ + { key: 'text', label: 'Text' }, + { key: 'speaker_id', label: 'Speaker ID' }, + { key: 'start_s', label: 'Start S', format: 'number' }, + { key: 'end_s', label: 'End S', format: 'number' }, + { + key: 'words', + label: 'Words', + listItems: [ + { key: 'text', label: 'Text' }, + { key: 'word_type', label: 'Word Type' }, + { key: 'start_s', label: 'Start S', format: 'number' }, + { key: 'end_s', label: 'End S', format: 'number' }, + { + key: 'characters', + label: 'Characters', + listItems: [ + { key: 'text', label: 'Text' }, + { key: 'start_s', label: 'Start S', format: 'number' }, + { key: 'end_s', label: 'End S', format: 'number' }, + ], + }, + ], + }, + ], + }, + ], +}; + +export const elevenlabsGetDubbingTranscriptFormattedOutputSchema: OutputSchema = { + fields: [ + { key: 'transcript_format', label: 'Transcript Format' }, + { key: 'srt', label: 'Srt' }, + ], +}; + +export const elevenlabsGetDubbingOutputSchema: OutputSchema = { + fields: [ + { key: 'dubbing_id', label: 'Dubbing ID' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'source_language', label: 'Source Language' }, + { key: 'target_languages', label: 'Target Languages' }, + { key: 'editable', label: 'Editable', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { + key: 'media_metadata', + label: 'Media Metadata', + children: [ + { key: 'content_type', label: 'Content Type' }, + { key: 'duration', label: 'Duration', format: 'number' }, + ], + }, + { key: 'error', label: 'Error' }, + { key: 'model', label: 'Model' }, + ], +}; + +export const elevenlabsListDubbingsOutputSchema: OutputSchema = { + fields: [ + { + key: 'dubs', + label: 'Dubs', + labelKey: 'name', + listItems: [ + { key: 'dubbing_id', label: 'Dubbing ID' }, + { key: 'name', label: 'Name' }, + { key: 'status', label: 'Status' }, + { key: 'source_language', label: 'Source Language' }, + { key: 'target_languages', label: 'Target Languages' }, + { key: 'editable', label: 'Editable', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'media_metadata', label: 'Media Metadata' }, + { key: 'error', label: 'Error' }, + { key: 'model', label: 'Model' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsListHistoryItemsOutputSchema: OutputSchema = { + fields: [ + { + key: 'history', + label: 'History', + listItems: [ + { key: 'history_item_id', label: 'History Item ID' }, + { key: 'request_id', label: 'Request ID' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'voice_name', label: 'Voice Name' }, + { key: 'voice_category', label: 'Voice Category' }, + { key: 'text', label: 'Text' }, + { key: 'date_unix', label: 'Date Unix', format: 'number' }, + { key: 'character_count_change_from', label: 'Character Count Change From', format: 'number' }, + { key: 'character_count_change_to', label: 'Character Count Change To', format: 'number' }, + { key: 'content_type', label: 'Content Type' }, + { key: 'state', label: 'State' }, + { + key: 'settings', + label: 'Settings', + children: [ + { key: 'similarity_boost', label: 'Similarity Boost', format: 'number' }, + { key: 'stability', label: 'Stability', format: 'number' }, + { key: 'speed', label: 'Speed', format: 'number' }, + { key: 'use_speaker_boost', label: 'Use Speaker Boost', format: 'boolean' }, + { key: 'style', label: 'Style', format: 'number' }, + ], + }, + { key: 'feedback', label: 'Feedback' }, + { key: 'share_link_id', label: 'Share Link ID' }, + { key: 'source', label: 'Source' }, + { key: 'alignments', label: 'Alignments' }, + { key: 'dialogue', label: 'Dialogue' }, + { key: 'output_format', label: 'Output Format' }, + { key: 'avatar_context', label: 'Avatar Context' }, + ], + }, + { key: 'last_history_item_id', label: 'Last History Item ID' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'scanned_until', label: 'Scanned Until' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsGetHistoryItemOutputSchema: OutputSchema = { + fields: [ + { key: 'history_item_id', label: 'History Item ID' }, + { key: 'request_id', label: 'Request ID' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'voice_name', label: 'Voice Name' }, + { key: 'voice_category', label: 'Voice Category' }, + { key: 'text', label: 'Text' }, + { key: 'date_unix', label: 'Date Unix', format: 'number' }, + { key: 'character_count_change_from', label: 'Character Count Change From', format: 'number' }, + { key: 'character_count_change_to', label: 'Character Count Change To', format: 'number' }, + { key: 'content_type', label: 'Content Type' }, + { key: 'state', label: 'State' }, + { + key: 'settings', + label: 'Settings', + children: [ + { key: 'style', label: 'Style', format: 'number' }, + { key: 'use_speaker_boost', label: 'Use Speaker Boost', format: 'boolean' }, + { key: 'stability', label: 'Stability', format: 'number' }, + { key: 'speed', label: 'Speed', format: 'number' }, + { key: 'similarity_boost', label: 'Similarity Boost', format: 'number' }, + ], + }, + { key: 'feedback', label: 'Feedback' }, + { key: 'share_link_id', label: 'Share Link ID' }, + { key: 'source', label: 'Source' }, + { + key: 'alignments', + label: 'Alignments', + children: [ + { + key: 'alignment', + label: 'Alignment', + children: [ + { key: 'characters', label: 'Characters' }, + { key: 'character_start_times_seconds', label: 'Character Start Times Seconds' }, + { key: 'character_end_times_seconds', label: 'Character End Times Seconds' }, + ], + }, + { + key: 'normalized_alignment', + label: 'Normalized Alignment', + children: [ + { key: 'characters', label: 'Characters' }, + { key: 'character_start_times_seconds', label: 'Character Start Times Seconds' }, + { key: 'character_end_times_seconds', label: 'Character End Times Seconds' }, + ], + }, + ], + }, + { key: 'dialogue', label: 'Dialogue' }, + { key: 'output_format', label: 'Output Format' }, + { key: 'avatar_context', label: 'Avatar Context' }, + ], +}; + +export const elevenlabsGetTestInvocationOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'ran_against_draft', label: 'Ran Against Draft', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'number' }, + { key: 'folder_id', label: 'Folder ID' }, + { key: 'repeat_count', label: 'Repeat Count', format: 'number' }, + { key: 'bucketing_status', label: 'Bucketing Status' }, + { key: 'result_groups', label: 'Result Groups' }, + { + key: 'test_runs', + label: 'Test Runs', + listItems: [ + { key: 'test_run_id', label: 'Test Run ID' }, + { + key: 'test_info', + label: 'Test Info', + children: [ + { key: 'from_conversation_metadata', label: 'From Conversation Metadata' }, + { key: 'dynamic_variables', label: 'Dynamic Variables' }, + { + key: 'chat_history', + label: 'Chat History', + listItems: [ + { key: 'role', label: 'Role' }, + { key: 'agent_metadata', label: 'Agent Metadata' }, + { key: 'message', label: 'Message' }, + { key: 'multivoice_message', label: 'Multivoice Message' }, + { key: 'tool_calls', label: 'Tool Calls' }, + { key: 'tool_results', label: 'Tool Results' }, + { key: 'feedback', label: 'Feedback' }, + { key: 'llm_override', label: 'Llm Override' }, + { key: 'producing_llm', label: 'Producing Llm' }, + { key: 'time_in_call_secs', label: 'Time In Call Secs', format: 'number' }, + { key: 'conversation_turn_metrics', label: 'Conversation Turn Metrics' }, + { key: 'rag_retrieval_info', label: 'Rag Retrieval Info' }, + { key: 'llm_usage', label: 'Llm Usage' }, + { key: 'interrupted', label: 'Interrupted', format: 'boolean' }, + { key: 'ignored_as_backchannel', label: 'Ignored As Backchannel', format: 'boolean' }, + { key: 'original_message', label: 'Original Message' }, + { key: 'reasoning', label: 'Reasoning' }, + { key: 'source_medium', label: 'Source Medium' }, + { key: 'source_event_id', label: 'Source Event ID' }, + { key: 'used_static_kb_document_ids', label: 'Used Static Kb Document IDs' }, + { key: 'analysis', label: 'Analysis' }, + { key: 'user_identifier', label: 'User Identifier' }, + { key: 'id', label: 'ID' }, + { key: 'compaction', label: 'Compaction' }, + { key: 'triggered_guardrails', label: 'Triggered Guardrails' }, + ], + }, + { key: 'conversation_initiation_source', label: 'Conversation Initiation Source' }, + { key: 'environment', label: 'Environment' }, + { key: 'type', label: 'Type' }, + { key: 'success_condition', label: 'Success Condition' }, + { + key: 'success_examples', + label: 'Success Examples', + listItems: [ + { key: 'response', label: 'Response' }, + { key: 'type', label: 'Type' }, + ], + }, + { + key: 'failure_examples', + label: 'Failure Examples', + listItems: [ + { key: 'response', label: 'Response' }, + { key: 'type', label: 'Type' }, + ], + }, + ], + }, + { key: 'test_invocation_id', label: 'Test Invocation ID' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'ran_against_draft', label: 'Ran Against Draft', format: 'boolean' }, + { key: 'workflow_node_id', label: 'Workflow Node ID' }, + { key: 'status', label: 'Status' }, + { + key: 'agent_responses', + label: 'Agent Responses', + listItems: [ + { key: 'role', label: 'Role' }, + { key: 'agent_metadata', label: 'Agent Metadata' }, + { key: 'message', label: 'Message' }, + { key: 'multivoice_message', label: 'Multivoice Message' }, + { key: 'tool_calls', label: 'Tool Calls' }, + { key: 'tool_results', label: 'Tool Results' }, + { key: 'feedback', label: 'Feedback' }, + { key: 'llm_override', label: 'Llm Override' }, + { key: 'producing_llm', label: 'Producing Llm' }, + { key: 'time_in_call_secs', label: 'Time In Call Secs', format: 'number' }, + { key: 'conversation_turn_metrics', label: 'Conversation Turn Metrics' }, + { key: 'rag_retrieval_info', label: 'Rag Retrieval Info' }, + { key: 'llm_usage', label: 'Llm Usage' }, + { key: 'interrupted', label: 'Interrupted', format: 'boolean' }, + { key: 'ignored_as_backchannel', label: 'Ignored As Backchannel', format: 'boolean' }, + { key: 'original_message', label: 'Original Message' }, + { key: 'reasoning', label: 'Reasoning' }, + { key: 'source_medium', label: 'Source Medium' }, + { key: 'source_event_id', label: 'Source Event ID', format: 'number' }, + { key: 'used_static_kb_document_ids', label: 'Used Static Kb Document IDs' }, + { key: 'analysis', label: 'Analysis' }, + { key: 'user_identifier', label: 'User Identifier' }, + { key: 'id', label: 'ID' }, + { key: 'compaction', label: 'Compaction' }, + { key: 'triggered_guardrails', label: 'Triggered Guardrails' }, + ], + }, + { key: 'test_id', label: 'Test ID' }, + { key: 'test_name', label: 'Test Name' }, + { + key: 'condition_result', + label: 'Condition Result', + children: [ + { key: 'result', label: 'Result' }, + { + key: 'rationale', + label: 'Rationale', + children: [ + { key: 'messages', label: 'Messages' }, + { key: 'summary', label: 'Summary' }, + ], + }, + ], + }, + { key: 'last_updated_at_unix', label: 'Last Updated At Unix', format: 'number' }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'workspace_id', label: 'Workspace ID' }, + { key: 'test_name', label: 'Test Name' }, + { key: 'ran_by_user_email', label: 'Ran By User Email', format: 'email' }, + { key: 'test_type', label: 'Test Type' }, + ], + }, + { key: 'root_folder_id', label: 'Root Folder ID' }, + { key: 'root_folder_name', label: 'Root Folder Name' }, + { key: 'environment', label: 'Environment' }, + { key: 'credits_used', label: 'Credits Used', format: 'number' }, + { + key: 'charging', + label: 'Charging', + children: [ + { key: 'dev_discount', label: 'Dev Discount', format: 'boolean' }, + { key: 'is_burst', label: 'Is Burst', format: 'boolean' }, + { key: 'tier', label: 'Tier' }, + { + key: 'llm_usage', + label: 'Llm Usage', + children: [ + { + key: 'irreversible_generation', + label: 'Irreversible Generation', + children: [ + { key: 'model_usage', label: 'Model Usage' }, + { key: 'detailed_model_usage', label: 'Detailed Model Usage' }, + ], + }, + { + key: 'initiated_generation', + label: 'Initiated Generation', + children: [ + { key: 'model_usage', label: 'Model Usage' }, + { key: 'detailed_model_usage', label: 'Detailed Model Usage' }, + ], + }, + ], + }, + { key: 'llm_price', label: 'Llm Price', format: 'number' }, + { key: 'llm_charge', label: 'Llm Charge', format: 'number' }, + { key: 'call_charge', label: 'Call Charge', format: 'number' }, + { key: 'platform_charge', label: 'Platform Charge', format: 'number' }, + { + key: 'platform_usage', + label: 'Platform Usage', + children: [ + { + key: 'category_usage', + label: 'Category Usage', + children: [ + { key: 'text_message', label: 'Text Message' }, + ], + }, + ], + }, + { key: 'platform_price', label: 'Platform Price', format: 'number' }, + { key: 'free_minutes_consumed', label: 'Free Minutes Consumed', format: 'number' }, + { key: 'free_llm_dollars_consumed', label: 'Free Llm Dollars Consumed', format: 'number' }, + { key: 'tts_usage', label: 'Tts Usage' }, + { key: 'asr_usage', label: 'Asr Usage' }, + { key: 'analysis', label: 'Analysis' }, + ], + }, + ], + }, + ], +}; + +export const elevenlabsListTestInvocationsOutputSchema: OutputSchema = { + fields: [ + { + key: 'meta', + label: 'Meta', + children: [ + { key: 'total', label: 'Total' }, + { key: 'page', label: 'Page' }, + { key: 'page_size', label: 'Page Size' }, + ], + }, + { + key: 'results', + label: 'Results', + labelKey: 'title', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'ran_against_draft', label: 'Ran Against Draft', format: 'boolean' }, + { key: 'runs_diverged_from_version', label: 'Runs Diverged From Version', format: 'boolean' }, + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'test_run_count', label: 'Test Run Count', format: 'number' }, + { key: 'passed_count', label: 'Passed Count', format: 'number' }, + { key: 'failed_count', label: 'Failed Count', format: 'number' }, + { key: 'pending_count', label: 'Pending Count', format: 'number' }, + { key: 'title', label: 'Title' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'repeat_count', label: 'Repeat Count', format: 'number' }, + { key: 'credits_used', label: 'Credits Used', format: 'number' }, + { key: 'total_price', label: 'Total Price', format: 'number' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsGetKbDocumentContentOutputSchema: OutputSchema = { + fields: [ + { key: 'result', label: 'Result', value: '' }, + ], +}; + +export const elevenlabsTextToSpeechOutputSchema: OutputSchema = { + fields: [ + { key: 'audio', label: 'Audio File', value: '', format: 'url' }, + ], +}; + +export const elevenlabsDeleteKbDocumentOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + ], +}; + +export const elevenlabsListKbDependentAgentsOutputSchema: OutputSchema = { + fields: [ + { key: 'agents', label: 'Agents' }, + { key: 'branches', label: 'Branches' }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsCreateKbFileDocumentOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'folder_path', label: 'Folder Path' }, + ], +}; + +export const elevenlabsGetKbDocumentOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'last_updated_at_unix_secs', label: 'Last Updated At Unix Secs', format: 'number' }, + { key: 'size_bytes', label: 'Size Bytes', format: 'number' }, + ], + }, + { key: 'supported_usages', label: 'Supported Usages' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'folder_parent_id', label: 'Folder Parent ID' }, + { key: 'chunking_override', label: 'Chunking Override' }, + { key: 'folder_path', label: 'Folder Path' }, + { key: 'type', label: 'Type' }, + { key: 'extracted_inner_html', label: 'Extracted Inner HTML' }, + { key: 'content_format', label: 'Content Format' }, + ], +}; + +export const elevenlabsListKbDocumentsOutputSchema: OutputSchema = { + fields: [ + { + key: 'documents', + label: 'Documents', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'last_updated_at_unix_secs', label: 'Last Updated At Unix Secs', format: 'number' }, + { key: 'size_bytes', label: 'Size Bytes', format: 'number' }, + ], + }, + { key: 'supported_usages', label: 'Supported Usages' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'folder_parent_id', label: 'Folder Parent ID' }, + { key: 'chunking_override', label: 'Chunking Override' }, + { key: 'folder_path', label: 'Folder Path' }, + { key: 'dependent_agents', label: 'Dependent Agents' }, + { key: 'type', label: 'Type' }, + { key: 'children_count', label: 'Children Count', format: 'number' }, + { key: 'document_count', label: 'Document Count', format: 'number' }, + { key: 'auto_sync_info', label: 'Auto Sync Info' }, + { key: 'external_sync_info', label: 'External Sync Info' }, + { key: 'is_frozen', label: 'Is Frozen', format: 'boolean' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsCreateKbRagIndexOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'model', label: 'Model' }, + { key: 'status', label: 'Status' }, + { key: 'progress_percentage', label: 'Progress Percentage', format: 'number' }, + { + key: 'document_model_index_usage', + label: 'Document Model Index Usage', + children: [ + { key: 'used_bytes', label: 'Used Bytes', format: 'number' }, + ], + }, + ], +}; + +export const elevenlabsGetKbDocumentRagIndexesOutputSchema: OutputSchema = { + fields: [ + { + key: 'indexes', + label: 'Indexes', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'model', label: 'Model' }, + { key: 'status', label: 'Status' }, + { key: 'progress_percentage', label: 'Progress Percentage', format: 'number' }, + { + key: 'document_model_index_usage', + label: 'Document Model Index Usage', + children: [ + { key: 'used_bytes', label: 'Used Bytes', format: 'number' }, + ], + }, + ], + }, + ], +}; + +export const elevenlabsGetKbRagIndexOverviewOutputSchema: OutputSchema = { + fields: [ + { key: 'total_used_bytes', label: 'Total Used Bytes', format: 'number' }, + { key: 'total_max_bytes', label: 'Total Max Bytes', format: 'number' }, + { key: 'models', label: 'Models' }, + ], +}; + +export const elevenlabsGetKbDocumentSourceUrlOutputSchema: OutputSchema = { + fields: [ + { key: 'signed_url', label: 'Signed URL', format: 'url' }, + ], +}; + +export const elevenlabsGetKbSummariesOutputSchema: OutputSchema = { + fields: [ + { + key: 'summaries', + label: 'Summaries By Document ID', + value: '', + dynamicKey: true, + labelKey: 'data.name', + children: [ + { key: 'status', label: 'Status' }, + { + key: 'data', + label: 'Data', + children: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'last_updated_at_unix_secs', label: 'Last Updated At Unix Secs', format: 'number' }, + { key: 'size_bytes', label: 'Size Bytes', format: 'number' }, + ], + }, + { key: 'supported_usages', label: 'Supported Usages' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'folder_parent_id', label: 'Folder Parent ID' }, + { key: 'chunking_override', label: 'Chunking Override' }, + { key: 'folder_path', label: 'Folder Path' }, + { key: 'dependent_agents', label: 'Dependent Agents' }, + { key: 'type', label: 'Type' }, + ], + }, + ], + }, + ], +}; + +export const elevenlabsGetLiveConversationCountOutputSchema: OutputSchema = { + fields: [ + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsListModelsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Items', + labelKey: 'name', + listItems: [ + { key: 'model_id', label: 'Model ID' }, + { key: 'name', label: 'Name' }, + { key: 'can_be_finetuned', label: 'Can Be Finetuned', format: 'boolean' }, + { key: 'can_do_text_to_speech', label: 'Can Do Text To Speech', format: 'boolean' }, + { key: 'can_do_voice_conversion', label: 'Can Do Voice Conversion', format: 'boolean' }, + { key: 'can_use_style', label: 'Can Use Style', format: 'boolean' }, + { key: 'can_use_speaker_boost', label: 'Can Use Speaker Boost', format: 'boolean' }, + { key: 'serves_pro_voices', label: 'Serves Pro Voices', format: 'boolean' }, + { key: 'token_cost_factor', label: 'Token Cost Factor', format: 'number' }, + { key: 'description', label: 'Description' }, + { key: 'requires_alpha_access', label: 'Requires Alpha Access', format: 'boolean' }, + { + key: 'max_characters_request_free_user', + label: 'Max Characters Request Free User', + format: 'number', + }, + { + key: 'max_characters_request_subscribed_user', + label: 'Max Characters Request Subscribed User', + format: 'number', + }, + { key: 'maximum_text_length_per_request', label: 'Maximum Text Length Per Request', format: 'number' }, + { + key: 'languages', + label: 'Languages', + labelKey: 'name', + listItems: [ + { key: 'language_id', label: 'Language ID' }, + { key: 'name', label: 'Name' }, + ], + }, + { + key: 'model_rates', + label: 'Model Rates', + children: [ + { key: 'character_cost_multiplier', label: 'Character Cost Multiplier', format: 'number' }, + { key: 'cost_discount_multiplier', label: 'Cost Discount Multiplier', format: 'number' }, + ], + }, + { key: 'concurrency_group', label: 'Concurrency Group' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsAddPronunciationRulesOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'version_rules_num', label: 'Version Rules Num', format: 'number' }, + ], +}; + +export const elevenlabsUpdatePronunciationDictionaryOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'latest_version_id', label: 'Latest Version ID' }, + { key: 'latest_version_rules_num', label: 'Latest Version Rules Num', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + { key: 'created_by', label: 'Created By' }, + { key: 'creation_time_unix', label: 'Creation Time Unix', format: 'number' }, + { key: 'archived_time_unix', label: 'Archived Time Unix', format: 'number' }, + { key: 'description', label: 'Description' }, + ], +}; + +export const elevenlabsCreatePronunciationDictionaryFromRulesOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'created_by', label: 'Created By' }, + { key: 'creation_time_unix', label: 'Creation Time Unix', format: 'number' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'version_rules_num', label: 'Version Rules Num', format: 'number' }, + { key: 'description', label: 'Description' }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + ], +}; + +export const elevenlabsGetPronunciationDictionaryOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'latest_version_id', label: 'Latest Version ID' }, + { key: 'latest_version_rules_num', label: 'Latest Version Rules Num', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + { key: 'created_by', label: 'Created By' }, + { key: 'creation_time_unix', label: 'Creation Time Unix', format: 'number' }, + { key: 'archived_time_unix', label: 'Archived Time Unix' }, + { key: 'description', label: 'Description' }, + { + key: 'rules', + label: 'Rules', + listItems: [ + { key: 'string_to_replace', label: 'String To Replace' }, + { key: 'case_sensitive', label: 'Case Sensitive', format: 'boolean' }, + { key: 'word_boundaries', label: 'Word Boundaries', format: 'boolean' }, + { key: 'type', label: 'Type' }, + { key: 'alias', label: 'Alias' }, + ], + }, + ], +}; + +export const elevenlabsListPronunciationDictionariesOutputSchema: OutputSchema = { + fields: [ + { + key: 'pronunciation_dictionaries', + label: 'Pronunciation Dictionaries', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'latest_version_id', label: 'Latest Version ID' }, + { key: 'latest_version_rules_num', label: 'Latest Version Rules Num', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + { key: 'created_by', label: 'Created By' }, + { key: 'creation_time_unix', label: 'Creation Time Unix', format: 'number' }, + { key: 'archived_time_unix', label: 'Archived Time Unix' }, + { key: 'description', label: 'Description' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsListPhoneNumbersOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Items' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsCreateSecretOutputSchema: OutputSchema = { + fields: [ + { key: 'type', label: 'Type' }, + { key: 'secret_id', label: 'Secret ID' }, + { key: 'name', label: 'Name' }, + ], +}; + +export const elevenlabsListSecretsOutputSchema: OutputSchema = { + fields: [ + { + key: 'secrets', + label: 'Secrets', + labelKey: 'name', + listItems: [ + { key: 'type', label: 'Type' }, + { key: 'secret_id', label: 'Secret ID' }, + { key: 'name', label: 'Name' }, + { + key: 'used_by', + label: 'Used By', + children: [ + { key: 'tools', label: 'Tools' }, + { key: 'tools_has_more', label: 'Tools Has More', format: 'boolean' }, + { key: 'agents', label: 'Agents' }, + { key: 'agents_has_more', label: 'Agents Has More', format: 'boolean' }, + { key: 'phone_numbers', label: 'Phone Numbers' }, + { key: 'phone_numbers_has_more', label: 'Phone Numbers Has More', format: 'boolean' }, + { key: 'mcp_servers', label: 'Mcp Servers' }, + { key: 'others', label: 'Others' }, + ], + }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsListSharedVoicesOutputSchema: OutputSchema = { + fields: [ + { + key: 'voices', + label: 'Voices', + labelKey: 'name', + listItems: [ + { key: 'public_owner_id', label: 'Public Owner ID' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'date_unix', label: 'Date Unix', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'accent', label: 'Accent' }, + { key: 'gender', label: 'Gender' }, + { key: 'age', label: 'Age' }, + { key: 'descriptive', label: 'Descriptive' }, + { key: 'use_case', label: 'Use Case' }, + { key: 'category', label: 'Category' }, + { key: 'language', label: 'Language' }, + { key: 'locale', label: 'Locale' }, + { key: 'description', label: 'Description' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + { key: 'usage_character_count_1y', label: 'Usage Character Count 1y', format: 'number' }, + { key: 'usage_character_count_7d', label: 'Usage Character Count 7d', format: 'number' }, + { + key: 'play_api_usage_character_count_1y', + label: 'Play API Usage Character Count 1y', + format: 'number', + }, + { key: 'cloned_by_count', label: 'Cloned By Count', format: 'number' }, + { key: 'rate', label: 'Rate', format: 'number' }, + { key: 'fiat_rate', label: 'Fiat Rate' }, + { key: 'free_users_allowed', label: 'Free Users Allowed', format: 'boolean' }, + { key: 'live_moderation_enabled', label: 'Live Moderation Enabled', format: 'boolean' }, + { key: 'featured', label: 'Featured', format: 'boolean' }, + { + key: 'verified_languages', + label: 'Verified Languages', + listItems: [ + { key: 'language', label: 'Language' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'accent', label: 'Accent' }, + { key: 'locale', label: 'Locale' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + ], + }, + { key: 'notice_period', label: 'Notice Period', format: 'number' }, + { key: 'instagram_username', label: 'Instagram Username' }, + { key: 'twitter_username', label: 'Twitter Username' }, + { key: 'youtube_username', label: 'Youtube Username' }, + { key: 'tiktok_username', label: 'Tiktok Username' }, + { key: 'image_url', label: 'Image URL' }, + { key: 'is_added_by_user', label: 'Is Added By User', format: 'boolean' }, + { key: 'is_bookmarked', label: 'Is Bookmarked' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'total_count', label: 'Total Count', format: 'number' }, + ], +}; + +export const elevenlabsGetConversationSignedUrlOutputSchema: OutputSchema = { + fields: [ + { key: 'signed_url', label: 'Signed URL' }, + ], +}; + +export const elevenlabsFindSimilarVoicesOutputSchema: OutputSchema = { + fields: [ + { + key: 'voices', + label: 'Voices', + labelKey: 'name', + listItems: [ + { key: 'public_owner_id', label: 'Public Owner ID' }, + { key: 'voice_id', label: 'Voice ID' }, + { key: 'date_unix', label: 'Date Unix', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'accent', label: 'Accent' }, + { key: 'gender', label: 'Gender' }, + { key: 'age', label: 'Age' }, + { key: 'descriptive', label: 'Descriptive' }, + { key: 'use_case', label: 'Use Case' }, + { key: 'category', label: 'Category' }, + { key: 'language', label: 'Language' }, + { key: 'locale', label: 'Locale' }, + { key: 'description', label: 'Description' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + { key: 'usage_character_count_1y', label: 'Usage Character Count 1y', format: 'number' }, + { key: 'usage_character_count_7d', label: 'Usage Character Count 7d', format: 'number' }, + { + key: 'play_api_usage_character_count_1y', + label: 'Play API Usage Character Count 1y', + format: 'number', + }, + { key: 'cloned_by_count', label: 'Cloned By Count', format: 'number' }, + { key: 'rate', label: 'Rate', format: 'number' }, + { key: 'fiat_rate', label: 'Fiat Rate' }, + { key: 'free_users_allowed', label: 'Free Users Allowed', format: 'boolean' }, + { key: 'live_moderation_enabled', label: 'Live Moderation Enabled', format: 'boolean' }, + { key: 'featured', label: 'Featured', format: 'boolean' }, + { + key: 'verified_languages', + label: 'Verified Languages', + listItems: [ + { key: 'language', label: 'Language' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'accent', label: 'Accent' }, + { key: 'locale', label: 'Locale' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + ], + }, + { key: 'notice_period', label: 'Notice Period', format: 'number' }, + { key: 'instagram_username', label: 'Instagram Username' }, + { key: 'twitter_username', label: 'Twitter Username' }, + { key: 'youtube_username', label: 'Youtube Username' }, + { key: 'tiktok_username', label: 'Tiktok Username' }, + { key: 'image_url', label: 'Image URL' }, + { key: 'is_added_by_user', label: 'Is Added By User', format: 'boolean' }, + { key: 'is_bookmarked', label: 'Is Bookmarked' }, + { key: 'orb_thumbnail_signed_urls', label: 'Orb Thumbnail Signed Urls' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'total_count', label: 'Total Count', format: 'number' }, + ], +}; + +export const elevenlabsSimulateAgentConversationOutputSchema: OutputSchema = { + fields: [ + { + key: 'simulated_conversation', + label: 'Simulated Conversation', + listItems: [ + { key: 'role', label: 'Role' }, + { + key: 'agent_metadata', + label: 'Agent Metadata', + children: [ + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'workflow_node_id', label: 'Workflow Node ID' }, + { key: 'version_id', label: 'Version ID' }, + ], + }, + { key: 'message', label: 'Message' }, + { key: 'multivoice_message', label: 'Multivoice Message' }, + { key: 'tool_calls', label: 'Tool Calls' }, + { key: 'tool_results', label: 'Tool Results' }, + { key: 'feedback', label: 'Feedback' }, + { key: 'llm_override', label: 'Llm Override' }, + { key: 'producing_llm', label: 'Producing Llm' }, + { key: 'time_in_call_secs', label: 'Time In Call Secs', format: 'number' }, + { + key: 'conversation_turn_metrics', + label: 'Conversation Turn Metrics', + children: [ + { + key: 'metrics', + label: 'Metrics', + children: [ + { + key: 'convai_llm_service_ttf_sentence', + label: 'Convai Llm Service Ttf Sentence', + children: [ + { key: 'elapsed_time', label: 'Elapsed Time', format: 'number' }, + ], + }, + { + key: 'convai_llm_service_ttfb', + label: 'Convai Llm Service Ttfb', + children: [ + { key: 'elapsed_time', label: 'Elapsed Time', format: 'number' }, + ], + }, + { + key: 'convai_llm_service_tt_last_sentence', + label: 'Convai Llm Service Tt Last Sentence', + children: [ + { key: 'elapsed_time', label: 'Elapsed Time', format: 'number' }, + ], + }, + ], + }, + { key: 'convai_asr_provider', label: 'Convai Asr Provider' }, + { key: 'convai_tts_model', label: 'Convai Tts Model' }, + { key: 'convai_tts_cascade', label: 'Convai Tts Cascade' }, + ], + }, + { key: 'rag_retrieval_info', label: 'Rag Retrieval Info' }, + { key: 'llm_usage', label: 'Llm Usage' }, + { key: 'interrupted', label: 'Interrupted', format: 'boolean' }, + { key: 'ignored_as_backchannel', label: 'Ignored As Backchannel', format: 'boolean' }, + { key: 'original_message', label: 'Original Message' }, + { key: 'reasoning', label: 'Reasoning' }, + { key: 'source_medium', label: 'Source Medium' }, + { key: 'source_event_id', label: 'Source Event ID', format: 'number' }, + { key: 'used_static_kb_document_ids', label: 'Used Static Kb Document IDs' }, + { key: 'analysis', label: 'Analysis' }, + { key: 'user_identifier', label: 'User Identifier' }, + { key: 'id', label: 'ID' }, + { key: 'compaction', label: 'Compaction' }, + { key: 'triggered_guardrails', label: 'Triggered Guardrails' }, + { key: 'file_input', label: 'File Input' }, + { key: 'file_inputs', label: 'File Inputs' }, + { key: 'contextual_update_info', label: 'Contextual Update Info' }, + { key: 'config_snapshot_id', label: 'Config Snapshot ID' }, + { key: 'reasoned', label: 'Reasoned', format: 'boolean' }, + ], + }, + { + key: 'analysis', + label: 'Analysis', + children: [ + { key: 'evaluation_criteria_results', label: 'Evaluation Criteria Results' }, + { key: 'data_collection_results', label: 'Data Collection Results' }, + { key: 'evaluation_criteria_results_list', label: 'Evaluation Criteria Results List' }, + { key: 'data_collection_results_list', label: 'Data Collection Results List' }, + { key: 'call_successful', label: 'Call Successful' }, + { key: 'call_success_score', label: 'Call Success Score' }, + { key: 'transcript_summary', label: 'Transcript Summary' }, + { key: 'call_summary_title', label: 'Call Summary Title' }, + { key: 'scoped', label: 'Scoped' }, + { key: 'sentiment_analysis', label: 'Sentiment Analysis' }, + ], + }, + { key: 'guardrails_result', label: 'Guardrails Result' }, + ], +}; + +export const elevenlabsGetSubscriptionOutputSchema: OutputSchema = { + fields: [ + { key: 'tier', label: 'Tier' }, + { key: 'character_count', label: 'Character Count', format: 'number' }, + { key: 'character_limit', label: 'Character Limit', format: 'number' }, + { key: 'max_character_limit_extension', label: 'Max Character Limit Extension', format: 'number' }, + { key: 'max_credit_limit_extension', label: 'Max Credit Limit Extension', format: 'number' }, + { key: 'can_extend_character_limit', label: 'Can Extend Character Limit', format: 'boolean' }, + { + key: 'allowed_to_extend_character_limit', + label: 'Allowed To Extend Character Limit', + format: 'boolean', + }, + { key: 'next_character_count_reset_unix', label: 'Next Character Count Reset Unix', format: 'number' }, + { key: 'voice_slots_used', label: 'Voice Slots Used', format: 'number' }, + { key: 'professional_voice_slots_used', label: 'Professional Voice Slots Used', format: 'number' }, + { + key: 'professional_voice_slots_used_in_workspace', + label: 'Professional Voice Slots Used In Workspace', + format: 'number', + }, + { key: 'voice_limit', label: 'Voice Limit', format: 'number' }, + { key: 'max_voice_add_edits', label: 'Max Voice Add Edits', format: 'number' }, + { key: 'voice_add_edit_counter', label: 'Voice Add Edit Counter', format: 'number' }, + { key: 'professional_voice_limit', label: 'Professional Voice Limit', format: 'number' }, + { key: 'can_extend_voice_limit', label: 'Can Extend Voice Limit', format: 'boolean' }, + { key: 'can_use_instant_voice_cloning', label: 'Can Use Instant Voice Cloning', format: 'boolean' }, + { + key: 'can_use_professional_voice_cloning', + label: 'Can Use Professional Voice Cloning', + format: 'boolean', + }, + { key: 'currency', label: 'Currency' }, + { + key: 'current_overage', + label: 'Current Overage', + children: [ + { key: 'amount', label: 'Amount' }, + { key: 'currency', label: 'Currency' }, + ], + }, + { key: 'status', label: 'Status' }, + { key: 'billing_period', label: 'Billing Period' }, + { key: 'character_refresh_period', label: 'Character Refresh Period' }, + { key: 'next_invoice', label: 'Next Invoice' }, + { key: 'open_invoices', label: 'Open Invoices' }, + { key: 'has_open_invoices', label: 'Has Open Invoices', format: 'boolean' }, + { key: 'pending_change', label: 'Pending Change' }, + { + key: 'has_used_starter_coupon_on_account', + label: 'Has Used Starter Coupon On Account', + format: 'boolean', + }, + { + key: 'has_used_creator_coupon_on_account', + label: 'Has Used Creator Coupon On Account', + format: 'boolean', + }, + ], +}; + +export const elevenlabsCreateAgentTestOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + ], +}; + +export const elevenlabsGetAgentTestOutputSchema: OutputSchema = { + fields: [ + { key: 'from_conversation_metadata', label: 'From Conversation Metadata' }, + { key: 'dynamic_variables', label: 'Dynamic Variables' }, + { + key: 'chat_history', + label: 'Chat History', + listItems: [ + { key: 'role', label: 'Role' }, + { key: 'agent_metadata', label: 'Agent Metadata' }, + { key: 'message', label: 'Message' }, + { key: 'multivoice_message', label: 'Multivoice Message' }, + { key: 'tool_calls', label: 'Tool Calls' }, + { key: 'tool_results', label: 'Tool Results' }, + { key: 'feedback', label: 'Feedback' }, + { key: 'llm_override', label: 'Llm Override' }, + { key: 'producing_llm', label: 'Producing Llm' }, + { key: 'time_in_call_secs', label: 'Time In Call Secs', format: 'number' }, + { key: 'conversation_turn_metrics', label: 'Conversation Turn Metrics' }, + { key: 'rag_retrieval_info', label: 'Rag Retrieval Info' }, + { key: 'llm_usage', label: 'Llm Usage' }, + { key: 'interrupted', label: 'Interrupted', format: 'boolean' }, + { key: 'ignored_as_backchannel', label: 'Ignored As Backchannel', format: 'boolean' }, + { key: 'original_message', label: 'Original Message' }, + { key: 'reasoning', label: 'Reasoning' }, + { key: 'source_medium', label: 'Source Medium' }, + { key: 'source_event_id', label: 'Source Event ID' }, + { key: 'used_static_kb_document_ids', label: 'Used Static Kb Document IDs' }, + { key: 'analysis', label: 'Analysis' }, + { key: 'user_identifier', label: 'User Identifier' }, + { key: 'id', label: 'ID' }, + { key: 'compaction', label: 'Compaction' }, + { key: 'triggered_guardrails', label: 'Triggered Guardrails' }, + ], + }, + { key: 'conversation_initiation_source', label: 'Conversation Initiation Source' }, + { key: 'environment', label: 'Environment' }, + { key: 'type', label: 'Type' }, + { key: 'success_condition', label: 'Success Condition' }, + { + key: 'success_examples', + label: 'Success Examples', + listItems: [ + { key: 'response', label: 'Response' }, + { key: 'type', label: 'Type' }, + ], + }, + { + key: 'failure_examples', + label: 'Failure Examples', + listItems: [ + { key: 'response', label: 'Response' }, + { key: 'type', label: 'Type' }, + ], + }, + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + ], +}; + +export const elevenlabsListAgentTestsOutputSchema: OutputSchema = { + fields: [ + { + key: 'tests', + label: 'Tests', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'last_updated_at_unix_secs', label: 'Last Updated At Unix Secs', format: 'number' }, + { key: 'type', label: 'Type' }, + { key: 'entity_type', label: 'Entity Type' }, + { key: 'folder_parent_id', label: 'Folder Parent ID' }, + { key: 'folder_path', label: 'Folder Path' }, + { key: 'children_count', label: 'Children Count' }, + { key: 'conversation_initiation_source', label: 'Conversation Initiation Source' }, + ], + }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsRunAgentTestsOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'ran_against_draft', label: 'Ran Against Draft', format: 'boolean' }, + { key: 'created_at', label: 'Created At', format: 'number' }, + { key: 'folder_id', label: 'Folder ID' }, + { key: 'repeat_count', label: 'Repeat Count', format: 'number' }, + { key: 'bucketing_status', label: 'Bucketing Status' }, + { key: 'result_groups', label: 'Result Groups' }, + { + key: 'test_runs', + label: 'Test Runs', + listItems: [ + { key: 'test_run_id', label: 'Test Run ID' }, + { key: 'test_info', label: 'Test Info' }, + { key: 'test_invocation_id', label: 'Test Invocation ID' }, + { key: 'agent_id', label: 'Agent ID' }, + { key: 'branch_id', label: 'Branch ID' }, + { key: 'version_id', label: 'Version ID' }, + { key: 'ran_against_draft', label: 'Ran Against Draft', format: 'boolean' }, + { key: 'workflow_node_id', label: 'Workflow Node ID' }, + { key: 'status', label: 'Status' }, + { key: 'agent_responses', label: 'Agent Responses' }, + { key: 'test_id', label: 'Test ID' }, + { key: 'test_name', label: 'Test Name' }, + { key: 'condition_result', label: 'Condition Result' }, + { key: 'last_updated_at_unix', label: 'Last Updated At Unix', format: 'number' }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'workspace_id', label: 'Workspace ID' }, + { key: 'test_name', label: 'Test Name' }, + { key: 'ran_by_user_email', label: 'Ran By User Email', format: 'email' }, + { key: 'test_type', label: 'Test Type' }, + ], + }, + { key: 'root_folder_id', label: 'Root Folder ID' }, + { key: 'root_folder_name', label: 'Root Folder Name' }, + { key: 'environment', label: 'Environment' }, + { key: 'credits_used', label: 'Credits Used' }, + { key: 'charging', label: 'Charging' }, + ], + }, + ], +}; + +export const elevenlabsGetAgentTestSummariesOutputSchema: OutputSchema = { + fields: [ + { + key: 'tests', + label: 'Tests By Test ID', + dynamicKey: true, + labelKey: 'name', + children: [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { key: 'created_at_unix_secs', label: 'Created At Unix Secs', format: 'number' }, + { key: 'last_updated_at_unix_secs', label: 'Last Updated At Unix Secs', format: 'number' }, + { key: 'type', label: 'Type' }, + { key: 'entity_type', label: 'Entity Type' }, + { key: 'folder_parent_id', label: 'Folder Parent ID' }, + { key: 'folder_path', label: 'Folder Path' }, + { key: 'children_count', label: 'Children Count' }, + { key: 'conversation_initiation_source', label: 'Conversation Initiation Source' }, + ], + }, + ], +}; + +export const elevenlabsCreateToolOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { + key: 'tool_config', + label: 'Tool Config', + children: [ + { key: 'type', label: 'Type' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'response_timeout_secs', label: 'Response Timeout Secs', format: 'number' }, + { key: 'disable_interruptions', label: 'Disable Interruptions', format: 'boolean' }, + { key: 'interruption_mode', label: 'Interruption Mode' }, + { key: 'force_pre_tool_speech', label: 'Force Pre Tool Speech', format: 'boolean' }, + { key: 'pre_tool_speech', label: 'Pre Tool Speech' }, + { key: 'assignments', label: 'Assignments' }, + { key: 'tool_call_sound', label: 'Tool Call Sound' }, + { key: 'tool_call_sound_behavior', label: 'Tool Call Sound Behavior' }, + { key: 'tool_error_handling_mode', label: 'Tool Error Handling Mode' }, + { + key: 'dynamic_variables', + label: 'Dynamic Variables', + children: [ + { key: 'dynamic_variable_placeholders', label: 'Dynamic Variable Placeholders' }, + ], + }, + { key: 'execution_mode', label: 'Execution Mode' }, + { + key: 'api_schema', + label: 'API Schema', + children: [ + { key: 'request_headers', label: 'Request Headers' }, + { key: 'kind', label: 'Kind' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'method', label: 'Method' }, + { key: 'path_params_schema', label: 'Path Params Schema' }, + { key: 'query_params_schema', label: 'Query Params Schema' }, + { + key: 'request_body_schema', + label: 'Request Body Schema', + children: [ + { key: 'description', label: 'Description' }, + { key: 'dynamic_variable', label: 'Dynamic Variable' }, + { key: 'is_omitted', label: 'Is Omitted', format: 'boolean' }, + { key: 'type', label: 'Type' }, + { key: 'required', label: 'Required' }, + { + key: 'properties', + label: 'Properties', + children: [ + { key: 'q', label: 'Q' }, + ], + }, + ], + }, + { key: 'response_body_schema', label: 'Response Body Schema' }, + { key: 'response_filter', label: 'Response Filter' }, + { key: 'content_type', label: 'Content Type' }, + { key: 'auth_resolved_params', label: 'Auth Resolved Params' }, + { key: 'auth_connection', label: 'Auth Connection' }, + { key: 'mtls_auth_connection', label: 'Mtls Auth Connection' }, + ], + }, + { key: 'follow_redirects', label: 'Follow Redirects', format: 'boolean' }, + { key: 'follow_redirects_allowed_domains', label: 'Follow Redirects Allowed Domains' }, + ], + }, + { + key: 'access_info', + label: 'Access Info', + children: [ + { key: 'is_creator', label: 'Is Creator', format: 'boolean' }, + { key: 'creator_name', label: 'Creator Name' }, + { key: 'creator_email', label: 'Creator Email', format: 'email' }, + { key: 'role', label: 'Role' }, + { key: 'anonymous_access_level_override', label: 'Anonymous Access Level Override' }, + { key: 'access_source', label: 'Access Source' }, + ], + }, + { + key: 'usage_stats', + label: 'Usage Stats', + children: [ + { key: 'total_calls', label: 'Total Calls', format: 'number' }, + { key: 'avg_latency_secs', label: 'Avg Latency Secs', format: 'number' }, + ], + }, + { key: 'response_mocks', label: 'Response Mocks' }, + ], +}; + +export const elevenlabsListToolsOutputSchema: OutputSchema = { + fields: [ + { key: 'tools', label: 'Tools' }, + { key: 'next_cursor', label: 'Next Cursor' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const elevenlabsGetUserOutputSchema: OutputSchema = { + fields: [ + { key: 'user_id', label: 'User ID' }, + { + key: 'subscription', + label: 'Subscription', + children: [ + { key: 'tier', label: 'Tier' }, + { key: 'character_count', label: 'Character Count', format: 'number' }, + { key: 'character_limit', label: 'Character Limit', format: 'number' }, + { key: 'max_character_limit_extension', label: 'Max Character Limit Extension', format: 'number' }, + { key: 'max_credit_limit_extension', label: 'Max Credit Limit Extension', format: 'number' }, + { key: 'can_extend_character_limit', label: 'Can Extend Character Limit', format: 'boolean' }, + { + key: 'allowed_to_extend_character_limit', + label: 'Allowed To Extend Character Limit', + format: 'boolean', + }, + { key: 'next_character_count_reset_unix', label: 'Next Character Count Reset Unix', format: 'number' }, + { key: 'voice_slots_used', label: 'Voice Slots Used', format: 'number' }, + { key: 'professional_voice_slots_used', label: 'Professional Voice Slots Used', format: 'number' }, + { + key: 'professional_voice_slots_used_in_workspace', + label: 'Professional Voice Slots Used In Workspace', + format: 'number', + }, + { key: 'voice_limit', label: 'Voice Limit', format: 'number' }, + { key: 'max_voice_add_edits', label: 'Max Voice Add Edits', format: 'number' }, + { key: 'voice_add_edit_counter', label: 'Voice Add Edit Counter', format: 'number' }, + { key: 'professional_voice_limit', label: 'Professional Voice Limit', format: 'number' }, + { key: 'can_extend_voice_limit', label: 'Can Extend Voice Limit', format: 'boolean' }, + { key: 'can_use_instant_voice_cloning', label: 'Can Use Instant Voice Cloning', format: 'boolean' }, + { + key: 'can_use_professional_voice_cloning', + label: 'Can Use Professional Voice Cloning', + format: 'boolean', + }, + { key: 'currency', label: 'Currency' }, + { + key: 'current_overage', + label: 'Current Overage', + children: [ + { key: 'amount', label: 'Amount' }, + { key: 'currency', label: 'Currency' }, + ], + }, + { key: 'status', label: 'Status' }, + { key: 'billing_period', label: 'Billing Period' }, + { key: 'character_refresh_period', label: 'Character Refresh Period' }, + ], + }, + { key: 'subscription_extras', label: 'Subscription Extras' }, + { key: 'is_new_user', label: 'Is New User', format: 'boolean' }, + { key: 'can_use_delayed_payment_methods', label: 'Can Use Delayed Payment Methods', format: 'boolean' }, + { key: 'is_onboarding_completed', label: 'Is Onboarding Completed', format: 'boolean' }, + { + key: 'is_onboarding_checklist_completed', + label: 'Is Onboarding Checklist Completed', + format: 'boolean', + }, + { key: 'show_compliance_terms', label: 'Show Compliance Terms', format: 'boolean' }, + { key: 'first_name', label: 'First Name' }, + { key: 'is_api_key_hashed', label: 'Is API Key Hashed', format: 'boolean' }, + { key: 'xi_api_key_preview', label: 'Xi API Key Preview' }, + { key: 'referral_link_code', label: 'Referral Link Code' }, + { key: 'partnerstack_partner_default_link', label: 'Partnerstack Partner Default Link' }, + { key: 'created_at', label: 'Created At', format: 'number' }, + { key: 'seat_type', label: 'Seat Type' }, + ], +}; + +export const elevenlabsGetVoiceOutputSchema: OutputSchema = { + fields: [ + { key: 'voice_id', label: 'Voice ID' }, + { key: 'name', label: 'Name' }, + { key: 'samples', label: 'Samples' }, + { key: 'category', label: 'Category' }, + { + key: 'fine_tuning', + label: 'Fine Tuning', + children: [ + { key: 'is_allowed_to_fine_tune', label: 'Is Allowed To Fine Tune', format: 'boolean' }, + { + key: 'state', + label: 'State', + children: [ + { key: 'eleven_multilingual_v2', label: 'Eleven Multilingual V2' }, + { key: 'eleven_v2_5_flash', label: 'Eleven V2 5 Flash' }, + { key: 'eleven_turbo_v2', label: 'Eleven Turbo V2' }, + { key: 'eleven_v2_flash', label: 'Eleven V2 Flash' }, + { key: 'eleven_flash_v2_5', label: 'Eleven Flash V2 5' }, + { key: 'eleven_turbo_v2_5', label: 'Eleven Turbo V2 5' }, + { key: 'eleven_flash_v2', label: 'Eleven Flash V2' }, + ], + }, + { key: 'verification_failures', label: 'Verification Failures' }, + { key: 'verification_attempts_count', label: 'Verification Attempts Count', format: 'number' }, + { key: 'manual_verification_requested', label: 'Manual Verification Requested', format: 'boolean' }, + { key: 'language', label: 'Language' }, + { key: 'progress', label: 'Progress' }, + { + key: 'message', + label: 'Message', + children: [ + { key: 'eleven_multilingual_v2', label: 'Eleven Multilingual V2' }, + { key: 'eleven_v2_5_flash', label: 'Eleven V2 5 Flash' }, + { key: 'eleven_turbo_v2', label: 'Eleven Turbo V2' }, + { key: 'eleven_v2_flash', label: 'Eleven V2 Flash' }, + { key: 'eleven_flash_v2_5', label: 'Eleven Flash V2 5' }, + { key: 'eleven_turbo_v2_5', label: 'Eleven Turbo V2 5' }, + { key: 'eleven_flash_v2', label: 'Eleven Flash V2' }, + ], + }, + { key: 'dataset_duration_seconds', label: 'Dataset Duration Seconds' }, + { key: 'verification_attempts', label: 'Verification Attempts' }, + { key: 'slice_ids', label: 'Slice IDs' }, + { key: 'manual_verification', label: 'Manual Verification' }, + { key: 'max_verification_attempts', label: 'Max Verification Attempts', format: 'number' }, + { + key: 'next_max_verification_attempts_reset_unix_ms', + label: 'Next Max Verification Attempts Reset Unix Ms', + format: 'number', + }, + ], + }, + { + key: 'labels', + label: 'Labels', + children: [ + { key: 'use_case', label: 'Use Case' }, + { key: 'descriptive', label: 'Descriptive' }, + { key: 'language', label: 'Language' }, + { key: 'accent', label: 'Accent' }, + { key: 'age', label: 'Age' }, + { key: 'gender', label: 'Gender' }, + ], + }, + { key: 'description', label: 'Description' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + { key: 'available_for_tiers', label: 'Available For Tiers' }, + { + key: 'settings', + label: 'Settings', + children: [ + { key: 'stability', label: 'Stability', format: 'number' }, + { key: 'use_speaker_boost', label: 'Use Speaker Boost', format: 'boolean' }, + { key: 'similarity_boost', label: 'Similarity Boost', format: 'number' }, + { key: 'style', label: 'Style', format: 'number' }, + { key: 'speed', label: 'Speed', format: 'number' }, + ], + }, + { key: 'sharing', label: 'Sharing' }, + { key: 'high_quality_base_model_ids', label: 'High Quality Base Model IDs' }, + { + key: 'verified_languages', + label: 'Verified Languages', + listItems: [ + { key: 'language', label: 'Language' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'accent', label: 'Accent' }, + { key: 'locale', label: 'Locale' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + ], + }, + { key: 'collection_ids', label: 'Collection IDs' }, + { key: 'safety_control', label: 'Safety Control' }, + { + key: 'voice_verification', + label: 'Voice Verification', + children: [ + { key: 'requires_verification', label: 'Requires Verification', format: 'boolean' }, + { key: 'is_verified', label: 'Is Verified', format: 'boolean' }, + { key: 'verification_failures', label: 'Verification Failures' }, + { key: 'verification_attempts_count', label: 'Verification Attempts Count', format: 'number' }, + { key: 'language', label: 'Language' }, + { key: 'verification_attempts', label: 'Verification Attempts' }, + ], + }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + { key: 'is_owner', label: 'Is Owner' }, + { key: 'is_legacy', label: 'Is Legacy', format: 'boolean' }, + { key: 'is_mixed', label: 'Is Mixed', format: 'boolean' }, + { key: 'favorited_at_unix', label: 'Favorited At Unix' }, + { key: 'created_at_unix', label: 'Created At Unix', format: 'number' }, + { key: 'is_bookmarked', label: 'Is Bookmarked' }, + { key: 'recording_quality', label: 'Recording Quality' }, + { key: 'labelling_status', label: 'Labelling Status' }, + { key: 'recording_quality_reason', label: 'Recording Quality Reason' }, + { key: 'orb_thumbnail_signed_urls', label: 'Orb Thumbnail Signed Urls' }, + ], +}; + +export const elevenlabsListVoicesOutputSchema: OutputSchema = { + fields: [ + { + key: 'voices', + label: 'Voices', + labelKey: 'name', + listItems: [ + { key: 'voice_id', label: 'Voice ID' }, + { key: 'name', label: 'Name' }, + { key: 'samples', label: 'Samples' }, + { key: 'category', label: 'Category' }, + { + key: 'fine_tuning', + label: 'Fine Tuning', + children: [ + { key: 'is_allowed_to_fine_tune', label: 'Is Allowed To Fine Tune', format: 'boolean' }, + { + key: 'state', + label: 'State', + children: [ + { key: 'eleven_multilingual_v2', label: 'Eleven Multilingual V2' }, + { key: 'eleven_v2_5_flash', label: 'Eleven V2 5 Flash' }, + { key: 'eleven_turbo_v2', label: 'Eleven Turbo V2' }, + { key: 'eleven_v2_flash', label: 'Eleven V2 Flash' }, + { key: 'eleven_flash_v2_5', label: 'Eleven Flash V2 5' }, + { key: 'eleven_turbo_v2_5', label: 'Eleven Turbo V2 5' }, + { key: 'eleven_flash_v2', label: 'Eleven Flash V2' }, + ], + }, + { key: 'verification_failures', label: 'Verification Failures' }, + { key: 'verification_attempts_count', label: 'Verification Attempts Count', format: 'number' }, + { + key: 'manual_verification_requested', + label: 'Manual Verification Requested', + format: 'boolean', + }, + { key: 'language', label: 'Language' }, + { + key: 'progress', + label: 'Progress', + children: [ + { key: 'eleven_v2_5_flash', label: 'Eleven V2 5 Flash', format: 'number' }, + { key: 'eleven_v2_flash', label: 'Eleven V2 Flash', format: 'number' }, + { key: 'eleven_flash_v2_5', label: 'Eleven Flash V2 5', format: 'number' }, + { key: 'eleven_flash_v2', label: 'Eleven Flash V2', format: 'number' }, + ], + }, + { + key: 'message', + label: 'Message', + children: [ + { key: 'eleven_multilingual_v2', label: 'Eleven Multilingual V2' }, + { key: 'eleven_v2_5_flash', label: 'Eleven V2 5 Flash' }, + { key: 'eleven_turbo_v2', label: 'Eleven Turbo V2' }, + { key: 'eleven_v2_flash', label: 'Eleven V2 Flash' }, + { key: 'eleven_flash_v2_5', label: 'Eleven Flash V2 5' }, + { key: 'eleven_turbo_v2_5', label: 'Eleven Turbo V2 5' }, + { key: 'eleven_flash_v2', label: 'Eleven Flash V2' }, + ], + }, + { key: 'dataset_duration_seconds', label: 'Dataset Duration Seconds' }, + { key: 'verification_attempts', label: 'Verification Attempts' }, + { key: 'slice_ids', label: 'Slice IDs' }, + { key: 'manual_verification', label: 'Manual Verification' }, + { key: 'max_verification_attempts', label: 'Max Verification Attempts', format: 'number' }, + { + key: 'next_max_verification_attempts_reset_unix_ms', + label: 'Next Max Verification Attempts Reset Unix Ms', + format: 'number', + }, + ], + }, + { + key: 'labels', + label: 'Labels', + children: [ + { key: 'use_case', label: 'Use Case' }, + { key: 'descriptive', label: 'Descriptive' }, + { key: 'language', label: 'Language' }, + { key: 'accent', label: 'Accent' }, + { key: 'age', label: 'Age' }, + { key: 'gender', label: 'Gender' }, + ], + }, + { key: 'description', label: 'Description' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + { key: 'available_for_tiers', label: 'Available For Tiers' }, + { key: 'settings', label: 'Settings' }, + { key: 'sharing', label: 'Sharing' }, + { key: 'high_quality_base_model_ids', label: 'High Quality Base Model IDs' }, + { + key: 'verified_languages', + label: 'Verified Languages', + listItems: [ + { key: 'language', label: 'Language' }, + { key: 'model_id', label: 'Model ID' }, + { key: 'accent', label: 'Accent' }, + { key: 'locale', label: 'Locale' }, + { key: 'preview_url', label: 'Preview URL', format: 'url' }, + ], + }, + { key: 'collection_ids', label: 'Collection IDs' }, + { key: 'safety_control', label: 'Safety Control' }, + { + key: 'voice_verification', + label: 'Voice Verification', + children: [ + { key: 'requires_verification', label: 'Requires Verification', format: 'boolean' }, + { key: 'is_verified', label: 'Is Verified', format: 'boolean' }, + { key: 'verification_failures', label: 'Verification Failures' }, + { key: 'verification_attempts_count', label: 'Verification Attempts Count', format: 'number' }, + { key: 'language', label: 'Language' }, + { key: 'verification_attempts', label: 'Verification Attempts' }, + ], + }, + { key: 'permission_on_resource', label: 'Permission On Resource' }, + { key: 'is_owner', label: 'Is Owner', format: 'boolean' }, + { key: 'is_legacy', label: 'Is Legacy', format: 'boolean' }, + { key: 'is_mixed', label: 'Is Mixed', format: 'boolean' }, + { key: 'favorited_at_unix', label: 'Favorited At Unix' }, + { key: 'created_at_unix', label: 'Created At Unix', format: 'number' }, + { key: 'is_bookmarked', label: 'Is Bookmarked' }, + { key: 'recording_quality', label: 'Recording Quality' }, + { key: 'labelling_status', label: 'Labelling Status' }, + { key: 'recording_quality_reason', label: 'Recording Quality Reason' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'total_count', label: 'Total Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; diff --git a/packages/pieces/community/exa/package.json b/packages/pieces/community/exa/package.json index a00b16e43862..d53faa660588 100644 --- a/packages/pieces/community/exa/package.json +++ b/packages/pieces/community/exa/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-exa", - "version": "0.2.1", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "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" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/exa/src/i18n/translation.json b/packages/pieces/community/exa/src/i18n/translation.json index 9685065d7e84..3456d67a6396 100644 --- a/packages/pieces/community/exa/src/i18n/translation.json +++ b/packages/pieces/community/exa/src/i18n/translation.json @@ -5,11 +5,25 @@ "Ask AI": "Ask AI", "Perform Search": "Perform Search", "Find Similar Links": "Find Similar Links", + "Start Agent Run": "Start Agent Run", + "Get Agent Run": "Get Agent Run", + "List Agent Runs": "List Agent Runs", + "Cancel Agent Run": "Cancel Agent Run", + "Search the Web": "Search the Web", + "Get Page Contents": "Get Page Contents", + "Answer a Question": "Answer a Question", "Custom API Call": "Custom API Call", "Retrieve clean HTML content from specified URLs.": "Retrieve clean HTML content from specified URLs.", "Provides direct answers to queries by summarizing results.": "Provides direct answers to queries by summarizing results.", "Search the web using semantic or keyword-based search.": "Search the web using semantic or keyword-based search.", "Find pages similar to a given URL.": "Find pages similar to a given URL.", + "Starts an Exa Agent run for deep research, list building or structured extraction. The run works in the background; use Get Agent Run to read the result.": "Starts an Exa Agent run for deep research, list building or structured extraction. The run works in the background; use Get Agent Run to read the result.", + "Gets the status and result of an Exa Agent run. Use it after Start Agent Run, for example behind a Delay step.": "Gets the status and result of an Exa Agent run. Use it after Start Agent Run, for example behind a Delay step.", + "Lists your team's Exa Agent runs, newest first, one page at a time.": "Lists your team's Exa Agent runs, newest first, one page at a time.", + "Cancels a queued or running Exa Agent run right away. The run returns no results, and usage so far is still billed.": "Cancels a queued or running Exa Agent run right away. The run returns no results, and usage so far is still billed.", + "Searches the web with Exa and returns flat result rows.": "Searches the web with Exa and returns flat result rows.", + "Fetches clean content for known URLs and reports which URLs failed.": "Fetches clean content for known URLs and reports which URLs failed.", + "Answers a question from live web results and returns the sources it used.": "Answers a question from live web results and returns the sources it used.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "URLs": "URLs", "Return Full Text": "Return Full Text", @@ -17,9 +31,15 @@ "Livecrawl Timeout (ms)": "Livecrawl Timeout (ms)", "Number of Subpages": "Number of Subpages", "Subpage Target": "Subpage Target", + "Include Highlights": "Include Highlights", + "Summary Question": "Summary Question", + "Max Content Age (hours)": "Max Content Age (hours)", + "Links per Page": "Links per Page", "Query": "Query", "Include Text Content": "Include Text Content", "Model": "Model", + "Instructions": "Instructions", + "User Country": "User Country", "Search Type": "Search Type", "Category": "Category", "Number of Results": "Number of Results", @@ -31,41 +51,70 @@ "End Published Date": "End Published Date", "Include Text": "Include Text", "Exclude Text": "Exclude Text", + "Max Text Characters": "Max Text Characters", + "Filter Unsafe Content": "Filter Unsafe Content", "URL": "URL", "Start Crawl Date (ISO)": "Start Crawl Date (ISO)", "End Crawl Date (ISO)": "End Crawl Date (ISO)", "Start Published Date (ISO)": "Start Published Date (ISO)", "End Published Date (ISO)": "End Published Date (ISO)", + "Task": "Task", + "Effort": "Effort", + "Output Schema": "Output Schema", + "Max Cost (USD)": "Max Cost (USD)", + "Previous Run ID": "Previous Run ID", + "Metadata": "Metadata", + "Run ID": "Run ID", + "Limit": "Limit", + "Cursor": "Cursor", + "Published After": "Published After", + "Published Before": "Published Before", + "Include Page Text": "Include Page Text", + "Highlights Focus": "Highlights Focus", + "Subpages": "Subpages", + "Subpage Keyword": "Subpage Keyword", + "Question": "Question", + "Include Source Text": "Include Source Text", "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", "Array of URLs to crawl": "Array of URLs to crawl", "If true, returns full page text. If false, disables text return.": "If true, returns full page text. If false, disables text return.", - "Options for livecrawling pages.": "Options for livecrawling pages.", + "Deprecated by Exa: prefer Max Content Age. Ignored when Max Content Age is set.": "Deprecated by Exa: prefer Max Content Age. Ignored when Max Content Age is set.", "Timeout for livecrawling in milliseconds.": "Timeout for livecrawling in milliseconds.", "Number of subpages to crawl.": "Number of subpages to crawl.", "Keyword(s) to find specific subpages.": "Keyword(s) to find specific subpages.", + "Also return the most relevant snippets from each page (billed as an extra content type).": "Also return the most relevant snippets from each page (billed as an extra content type).", + "Ask for an AI summary of each page focused on this question, e.g. \"What does this company sell?\" (billed per page).": "Ask for an AI summary of each page focused on this question, e.g. \"What does this company sell?\" (billed per page).", + "Use cached content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Replaces Livecrawl Option.": "Use cached content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Replaces Livecrawl Option.", + "Also return up to this many links found on each page, e.g. 10.": "Also return up to this many links found on each page, e.g. 10.", "Ask a question to get summarized answers from the web.": "Ask a question to get summarized answers from the web.", "If true, includes full text content from the search results": "If true, includes full text content from the search results", "Choose the Exa model to use for the answer.": "Choose the Exa model to use for the answer.", + "Extra guidance for the answer, such as tone, length or preferred sources, e.g. \"Answer in two sentences and prefer official sources.\"": "Extra guidance for the answer, such as tone, length or preferred sources, e.g. \"Answer in two sentences and prefer official sources.\"", + "Two-letter country code to localize the search behind the answer, e.g. \"US\".": "Two-letter country code to localize the search behind the answer, e.g. \"US\".", "Search query to find related articles and data.": "Search query to find related articles and data.", - "Type of search to perform.": "Type of search to perform.", - "Category of data to focus the search on.": "Category of data to focus the search on.", + "Type of search to perform. Auto suits most searches; Instant and Fast trade depth for speed; Deep Lite, Deep and Deep Reasoning research more and cost more ($0.012 to $0.015 per search). Keyword and Neural are legacy options that Exa no longer documents.": "Type of search to perform. Auto suits most searches; Instant and Fast trade depth for speed; Deep Lite, Deep and Deep Reasoning research more and cost more ($0.012 to $0.015 per search). Keyword and Neural are legacy options that Exa no longer documents.", + "Category of data to focus the search on. Company and People do not support published-date filters or Exclude Domains.": "Category of data to focus the search on. Company and People do not support published-date filters or Exclude Domains.", "Number of results to return (max 100).": "Number of results to return (max 100).", "Limit results to only these domains.": "Limit results to only these domains.", "Exclude results from these domains.": "Exclude results from these domains.", - "Only include results crawled after this ISO date.": "Only include results crawled after this ISO date.", - "Only include results crawled before this ISO date.": "Only include results crawled before this ISO date.", + "Deprecated: Exa now ignores this filter. Use the published date filters instead.": "Deprecated: Exa now ignores this filter. Use the published date filters instead.", "Only include results published after this ISO date.": "Only include results published after this ISO date.", "Only include results published before this ISO date.": "Only include results published before this ISO date.", - "Strings that must be present in the text of results.": "Strings that must be present in the text of results.", - "Strings that must not be present in the text of results.": "Strings that must not be present in the text of results.", + "Deprecated by Exa. Strings that must be present in the text of results.": "Deprecated by Exa. Strings that must be present in the text of results.", + "Deprecated by Exa. Strings that must not be present in the text of results.": "Deprecated by Exa. Strings that must not be present in the text of results.", + "Limit the page text returned per result to this many characters, e.g. 2000. Leave empty for full text.": "Limit the page text returned per result to this many characters, e.g. 2000. Leave empty for full text.", + "Also return the most relevant snippets from each page.": "Also return the most relevant snippets from each page.", + "Use cached page content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Leave empty for Exa's default.": "Use cached page content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Leave empty for Exa's default.", + "Two-letter country code to localize results, e.g. \"US\" or \"DE\".": "Two-letter country code to localize results, e.g. \"US\" or \"DE\".", + "Remove unsafe content from the results.": "Remove unsafe content from the results.", "Reference URL to find semantically similar links.": "Reference URL to find semantically similar links.", "List of domains to include in results.": "List of domains to include in results.", "List of domains to exclude from results.": "List of domains to exclude from results.", @@ -75,16 +124,63 @@ "Only include links published before this date (ISO format).": "Only include links published before this date (ISO format).", "Strings that must be present in the webpage text (max 1 string of up to 5 words).": "Strings that must be present in the webpage text (max 1 string of up to 5 words).", "Strings that must not be present in the webpage text (max 1 string of up to 5 words).": "Strings that must not be present in the webpage text (max 1 string of up to 5 words).", + "What the agent should research or build, in plain language. Example: \"List 10 Series A climate-tech startups in Europe founded after 2022, with their website and lead investor.\"": "What the agent should research or build, in plain language. Example: \"List 10 Series A climate-tech startups in Europe founded after 2022, with their website and lead investor.\"", + "How hard the agent works, which sets the price per run. Minimal to Extra High are fixed prices. Auto and Ultra are metered by usage and can cost up to $5 and $20 per run unless you set Max Cost. Defaults to Low.": "How hard the agent works, which sets the price per run. Minimal to Extra High are fixed prices. Auto and Ultra are metered by usage and can cost up to $5 and $20 per run unless you set Max Cost. Defaults to Low.", + "Extra guidance for the agent, such as preferred sources or rules like \"only use official company websites\".": "Extra guidance for the agent, such as preferred sources or rules like \"only use official company websites\".", + "Optional JSON Schema for a structured result, returned in Structured Output. Example: {\"type\":\"object\",\"properties\":{\"companies\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"website\":{\"type\":\"string\"}}}}}}": "Optional JSON Schema for a structured result, returned in Structured Output. Example: {\"type\":\"object\",\"properties\":{\"companies\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"website\":{\"type\":\"string\"}}}}}}", + "Spending cap for one run, between 1 and 100. Only applies when Effort is Auto or Ultra; fixed efforts already have a fixed price.": "Spending cap for one run, between 1 and 100. Only applies when Effort is Auto or Ultra; fixed efforts already have a fixed price.", + "Continue from an earlier run so the agent builds on its findings. Use the ID returned by Start Agent Run, e.g. \"agent_run_01k4d9w6y3h7p2m8\".": "Continue from an earlier run so the agent builds on its findings. Use the ID returned by Start Agent Run, e.g. \"agent_run_01k4d9w6y3h7p2m8\".", + "Optional key-value labels stored with the run, e.g. {\"flow\": \"lead-research\"}.": "Optional key-value labels stored with the run, e.g. {\"flow\": \"lead-research\"}.", + "The ID returned by Start Agent Run (or List Agent Runs), e.g. \"agent_run_01k4d9w6y3h7p2m8\".": "The ID returned by Start Agent Run (or List Agent Runs), e.g. \"agent_run_01k4d9w6y3h7p2m8\".", + "How many runs to return in this page, 1 to 100. Defaults to 20.": "How many runs to return in this page, 1 to 100. Defaults to 20.", + "Leave empty for the first page. To get the next page, paste the Next Cursor value from the previous result.": "Leave empty for the first page. To get the next page, paste the Next Cursor value from the previous result.", + "What to search for, in natural language, e.g. \"latest research on solid-state batteries\".": "What to search for, in natural language, e.g. \"latest research on solid-state batteries\".", + "One of 'auto' (default), 'fast', 'instant', 'deep-lite', 'deep', 'deep-reasoning'. Deep modes are slower and cost more.": "One of 'auto' (default), 'fast', 'instant', 'deep-lite', 'deep', 'deep-reasoning'. Deep modes are slower and cost more.", + "Optional focus: 'company', 'people', 'news', 'publication', 'personal site' or 'financial report'.": "Optional focus: 'company', 'people', 'news', 'publication', 'personal site' or 'financial report'.", + "How many results to return, 1 to 100. Defaults to 10; each result above 10 costs $0.001.": "How many results to return, 1 to 100. Defaults to 10; each result above 10 costs $0.001.", + "Only return results from these domains or paths, e.g. \"arxiv.org\" or \"example.com/blog\".": "Only return results from these domains or paths, e.g. \"arxiv.org\" or \"example.com/blog\".", + "Never return results from these domains. Not allowed with the company or people category.": "Never return results from these domains. Not allowed with the company or people category.", + "Only results published after this ISO 8601 date, e.g. \"2026-01-01T00:00:00Z\". Not allowed with the company or people category.": "Only results published after this ISO 8601 date, e.g. \"2026-01-01T00:00:00Z\". Not allowed with the company or people category.", + "Only results published before this ISO 8601 date. Not allowed with the company or people category.": "Only results published before this ISO 8601 date. Not allowed with the company or people category.", + "Return the page text for each result.": "Return the page text for each result.", + "Cap the page text per result, e.g. 2000. Only used when page text is included.": "Cap the page text per result, e.g. 2000. Only used when page text is included.", + "Return the most relevant snippets from each page.": "Return the most relevant snippets from each page.", + "Optional question that steers which snippets are picked. Turns highlights on.": "Optional question that steers which snippets are picked. Turns highlights on.", + "Return a per-page AI summary focused on this question, e.g. \"What does this company sell?\" ($0.001 per page).": "Return a per-page AI summary focused on this question, e.g. \"What does this company sell?\" ($0.001 per page).", + "Use cached content only if newer than this many hours; 0 always fetches fresh, -1 always uses cache, max 720.": "Use cached content only if newer than this many hours; 0 always fetches fresh, -1 always uses cache, max 720.", + "Two-letter ISO country code to localize results, e.g. \"US\".": "Two-letter ISO country code to localize results, e.g. \"US\".", + "Full URLs to fetch, including https://, e.g. \"https://exa.ai/docs\". Up to 100.": "Full URLs to fetch, including https://, e.g. \"https://exa.ai/docs\". Up to 100.", + "Return the page text.": "Return the page text.", + "Cap the text per page, e.g. 5000. Only used when page text is included.": "Cap the text per page, e.g. 5000. Only used when page text is included.", + "Return a per-page AI summary focused on this question, e.g. \"Who are the founders?\".": "Return a per-page AI summary focused on this question, e.g. \"Who are the founders?\".", + "Also fetch up to this many linked subpages per URL, e.g. 3.": "Also fetch up to this many linked subpages per URL, e.g. 3.", + "Prefer subpages matching this keyword, e.g. \"pricing\" or \"about\".": "Prefer subpages matching this keyword, e.g. \"pricing\" or \"about\".", + "Also return up to this many links found on each page.": "Also return up to this many links found on each page.", + "The question to answer, e.g. \"What is the latest stable version of Node.js?\".": "The question to answer, e.g. \"What is the latest stable version of Node.js?\".", + "'exa' (default), 'exa-pro' for harder questions, or 'exa-fast' for lower latency.": "'exa' (default), 'exa-pro' for harder questions, or 'exa-fast' for lower latency.", + "Optional guidance for the answer, e.g. \"Answer in two sentences and cite official sources only.\"": "Optional guidance for the answer, e.g. \"Answer in two sentences and cite official sources only.\"", + "Two-letter ISO country code to localize the search, e.g. \"US\".": "Two-letter ISO country code to localize the search, e.g. \"US\".", + "Also return the full text of each cited page.": "Also return the full text of each cited page.", "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.", "Never": "Never", "Fallback": "Fallback", "Always": "Always", "Auto": "Auto", "Exa": "Exa", "Exa Pro": "Exa Pro", - "Keyword": "Keyword", - "Neural": "Neural", + "Exa Fast": "Exa Fast", + "Keyword (legacy)": "Keyword (legacy)", + "Neural (legacy)": "Neural (legacy)", + "Instant": "Instant", + "Fast": "Fast", + "Deep Lite": "Deep Lite", + "Deep": "Deep", + "Deep Reasoning": "Deep Reasoning", "Company": "Company", "Research Paper": "Research Paper", "News": "News", @@ -94,14 +190,39 @@ "Personal Site": "Personal Site", "LinkedIn Profile": "LinkedIn Profile", "Financial Report": "Financial Report", + "Publication": "Publication", + "People": "People", + "Minimal ($0.012 per run)": "Minimal ($0.012 per run)", + "Low ($0.025 per run)": "Low ($0.025 per run)", + "Medium ($0.10 per run)": "Medium ($0.10 per run)", + "High ($0.50 per run)": "High ($0.50 per run)", + "Extra High ($1.00 per run)": "Extra High ($1.00 per run)", + "Auto (metered, up to $5 by default)": "Auto (metered, up to $5 by default)", + "Ultra (metered, up to $20 by default)": "Ultra (metered, up to $20 by default)", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw" + "New Search Monitor Results": "New Search Monitor Results", + "Runs a search on a schedule and triggers with the new results Exa found since the last run.": "Runs a search on a schedule and triggers with the new results Exa found since the last run.", + "Markdown": "Markdown", + "Search Query": "Search Query", + "Check Every": "Check Every", + "Max Results per Run": "Max Results per Run", + "Monitor Name": "Monitor Name", + "Exa creates a **Monitor** that repeats this search on a schedule and sends only new results.\n\n- Each run costs about **$0.015** on your Exa account (plus $0.001 per result above 10). An hourly monitor is about 720 runs a month (≈ $11); daily is about 30 (≈ $0.45).\n- Exa only delivers to a public **HTTPS** address. On a self-hosted instance, the Activepieces webhook URL must be reachable from the internet.\n- Testing the trigger in the builder runs one search (about $0.007, plus content charges when page text": "Exa creates a **Monitor** that repeats this search on a schedule and sends only new results.\n\n- Each run costs about **$0.015** on your Exa account (plus $0.001 per result above 10). An hourly monitor is about 720 runs a month (≈ $11); daily is about 30 (≈ $0.45).\n- Exa only delivers to a public **HTTPS** address. On a self-hosted instance, the Activepieces webhook URL must be reachable from the internet.\n- Testing the trigger in the builder runs one search (about $0.007, plus content charges when page text or highlights are on) and shows its results; it does not create a monitor.\n- Results that were already delivered are not sent again, also after the flow is republished. A run whose results were all delivered before is skipped. When a run mixes old and new results, only the new ones are sent, with their citations; Summary is then empty, and Run Summary and Run Citations cover the whole run. Numbered markers such as [1] in Exa's summaries are Exa's own; Exa does not document what they point to, so use the citation lists for sources.\n- Turning the flow off deletes the monitor, retrying if Exa is briefly unavailable. If it still can't be deleted, remove it in the Exa dashboard: it is named after this flow unless you set Monitor Name.", + "What to watch for, written as the ongoing signal rather than a date range. Example: \"new battery recycling facilities announced in North America\".": "What to watch for, written as the ongoing signal rather than a date range. Example: \"new battery recycling facilities announced in North America\".", + "How often Exa runs the search. Each run may start up to 30 minutes late.": "How often Exa runs the search. Each run may start up to 30 minutes late.", + "Up to how many new results each run returns, 1 to 100. Defaults to 10; results above 10 cost extra.": "Up to how many new results each run returns, 1 to 100. Defaults to 10; results above 10 cost extra.", + "Only return results from these domains, e.g. \"techcrunch.com\". Leave empty for the whole web.": "Only return results from these domains, e.g. \"techcrunch.com\". Leave empty for the whole web.", + "Never return results from these domains.": "Never return results from these domains.", + "Add the full page text to each result.": "Add the full page text to each result.", + "Add the most relevant snippets from each page to each result.": "Add the most relevant snippets from each page to each result.", + "Optional name shown for this monitor in the Exa dashboard.": "Optional name shown for this monitor in the Exa dashboard.", + "Hour": "Hour", + "6 hours": "6 hours", + "Day": "Day", + "Week": "Week" } \ No newline at end of file diff --git a/packages/pieces/community/exa/src/index.ts b/packages/pieces/community/exa/src/index.ts index 0c24a73c7265..3d21b1fa991a 100644 --- a/packages/pieces/community/exa/src/index.ts +++ b/packages/pieces/community/exa/src/index.ts @@ -1,20 +1,25 @@ -import { createPiece, PieceAuth } from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { getContentsAction } from './lib/actions/get-contents'; import { generateAnswerAction } from './lib/actions/generate-answer'; import { performSearchAction } from './lib/actions/perform-search'; import { findSimilarLinksAction } from './lib/actions/find-similar-links'; -import { createCustomApiCallAction, HttpMethod } from '@activepieces/pieces-common'; -import { makeRequest } from './lib/common'; +import { createAgentRunAction } from './lib/actions/agent/create-agent-run'; +import { getAgentRunAction } from './lib/actions/agent/get-agent-run'; +import { listAgentRunsAction } from './lib/actions/agent/list-agent-runs'; +import { cancelAgentRunAction } from './lib/actions/agent/cancel-agent-run'; +import { exaSearchAction } from './lib/actions/ai/exa-search'; +import { exaGetContentsAction } from './lib/actions/ai/exa-get-contents'; +import { exaAnswerAction } from './lib/actions/ai/exa-answer'; +import { newSearchMonitorResultsTrigger } from './lib/triggers/new-search-monitor-results'; +import { exaCustomApiCallAction } from './lib/actions/custom-api-call'; import { exaAuth } from './lib/auth'; -const markdownDescription = `Obtain your API key from [Dashboard Setting](https://dashboard.exa.ai/api-keys).`; - export const exa = createPiece({ displayName: 'Exa', description: 'AI-powered search and content extraction from the web.', auth: exaAuth, - minimumSupportedRelease: '0.36.1', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/exa.png', categories: [PieceCategory.ARTIFICIAL_INTELLIGENCE,PieceCategory.PRODUCTIVITY], authors: ['krushnarout','kishanprmr'], @@ -23,13 +28,14 @@ export const exa = createPiece({ generateAnswerAction, performSearchAction, findSimilarLinksAction, - createCustomApiCallAction({ - auth:exaAuth, - baseUrl: () => 'https://api.exa.ai', - authMapping: async (auth) => ({ - 'x-api-key': `${auth}`, - }), - }) + createAgentRunAction, + getAgentRunAction, + listAgentRunsAction, + cancelAgentRunAction, + exaSearchAction, + exaGetContentsAction, + exaAnswerAction, + exaCustomApiCallAction, ], - triggers: [], + triggers: [newSearchMonitorResultsTrigger], }); diff --git a/packages/pieces/community/exa/src/lib/actions/agent/cancel-agent-run.ts b/packages/pieces/community/exa/src/lib/actions/agent/cancel-agent-run.ts new file mode 100644 index 000000000000..cf0dd169c55b --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/agent/cancel-agent-run.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { AgentRun, agentRuns } from '../../common/agent'; +import { agentRunOutputSchema } from '../../output-schemas'; + +export const cancelAgentRunAction = createAction({ + name: 'exa_cancel_agent_run', + classification: 'DESTRUCTIVE', + displayName: 'Cancel Agent Run', + description: + 'Cancels a queued or running Exa Agent run right away. The run returns no results, and usage so far is still billed.', + audience: 'both', + aiMetadata: { + description: + 'Cancels a queued or running Exa Agent run immediately, discarding its results; usage accrued before the cancel is still billed. Use to stop spend on a run that is no longer needed; to read a finished run use exa_get_agent_run instead. Safe to retry: a run that already finished is returned unchanged.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: agentRunOutputSchema, + props: { + runId: Property.ShortText({ + displayName: 'Run ID', + description: + 'The ID returned by Start Agent Run (or List Agent Runs), e.g. "agent_run_01k4d9w6y3h7p2m8".', + required: true, + }), + }, + async run(context) { + const runId = exaInput.requiredId({ value: context.propsValue.runId, name: 'Run ID' }); + const run = await exaApi.call({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: `/agent/runs/${encodeURIComponent(runId)}/cancel`, + }); + return agentRuns.flattenRun(run); + }, +}); diff --git a/packages/pieces/community/exa/src/lib/actions/agent/create-agent-run.ts b/packages/pieces/community/exa/src/lib/actions/agent/create-agent-run.ts new file mode 100644 index 000000000000..f0004cdc339d --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/agent/create-agent-run.ts @@ -0,0 +1,139 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { AgentRun, agentEfforts, agentRuns } from '../../common/agent'; +import { agentRunOutputSchema } from '../../output-schemas'; + +export const createAgentRunAction = createAction({ + name: 'exa_create_agent_run', + classification: 'WRITE', + displayName: 'Start Agent Run', + description: + 'Starts an Exa Agent run for deep research, list building or structured extraction. The run works in the background; use Get Agent Run to read the result.', + audience: 'both', + aiMetadata: { + description: + 'Starts an asynchronous Exa Agent run that researches a natural-language task on the web and can return JSON shaped by an optional output schema. Use for multi-step research or list building that a single search cannot answer; it returns right away with the run id and a queued or running status, so poll exa_get_agent_run until status is completed, failed or cancelled. Effort sets the price (minimal $0.012 to xhigh $1.00; auto and ultra are metered up to $5/$20), defaulting to low. Not idempotent: each call starts and bills a new run.', + idempotent: false, + }, + auth: exaAuth, + outputSchema: agentRunOutputSchema, + props: { + query: Property.LongText({ + displayName: 'Task', + description: + 'What the agent should research or build, in plain language. Example: "List 10 Series A climate-tech startups in Europe founded after 2022, with their website and lead investor."', + required: true, + }), + effort: Property.StaticDropdown({ + displayName: 'Effort', + description: + 'How hard the agent works, which sets the price per run. Minimal to Extra High are fixed prices. Auto and Ultra are metered by usage and can cost up to $5 and $20 per run unless you set Max Cost. Defaults to Low.', + required: false, + defaultValue: agentEfforts.DEFAULT, + options: { options: agentEfforts.OPTIONS }, + }), + systemPrompt: Property.LongText({ + displayName: 'Instructions', + description: + 'Extra guidance for the agent, such as preferred sources or rules like "only use official company websites".', + required: false, + }), + outputSchema: Property.Json({ + displayName: 'Output Schema', + description: + 'Optional JSON Schema for a structured result, returned in Structured Output. Example: {"type":"object","properties":{"companies":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"website":{"type":"string"}}}}}}', + required: false, + }), + maxCostDollars: Property.Number({ + displayName: 'Max Cost (USD)', + description: + 'Spending cap for one run, between 1 and 100. Only applies when Effort is Auto or Ultra; fixed efforts already have a fixed price.', + required: false, + }), + previousRunId: Property.ShortText({ + displayName: 'Previous Run ID', + description: + 'Continue from an earlier run so the agent builds on its findings. Use the ID returned by Start Agent Run, e.g. "agent_run_01k4d9w6y3h7p2m8".', + required: false, + }), + metadata: Property.Object({ + displayName: 'Metadata', + description: 'Optional key-value labels stored with the run, e.g. {"flow": "lead-research"}.', + required: false, + }), + }, + async run(context) { + const { query, systemPrompt, previousRunId, metadata } = context.propsValue; + const effort = effortOf(context.propsValue.effort); + const maxCostDollars = exaInput.optionalNumber({ + value: context.propsValue.maxCostDollars, + name: 'Max Cost (USD)', + min: 1, + max: 100, + }); + if (maxCostDollars !== undefined && !agentEfforts.METERED.includes(effort)) { + throw new Error('Max Cost only applies when Effort is Auto or Ultra. Clear it or change the effort.'); + } + const outputSchema = schemaOf(context.propsValue.outputSchema); + const metadataValues = metadataOf(metadata); + const instructions = exaInput.optionalText(systemPrompt); + const previous = exaInput.optionalText(previousRunId); + + const body = { + query, + effort, + ...(instructions ? { systemPrompt: instructions } : {}), + ...(outputSchema ? { outputSchema } : {}), + ...(maxCostDollars !== undefined ? { budget: { maxCostDollars } } : {}), + ...(previous ? { previousRunId: previous } : {}), + ...(metadataValues ? { metadata: metadataValues } : {}), + }; + + const run = await exaApi.call({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: '/agent/runs', + body, + }); + return agentRuns.flattenRun(run); + }, +}); + +function effortOf(value: unknown): string { + const effort = exaInput.optionalText(value) ?? agentEfforts.DEFAULT; + if (!agentEfforts.ALL.includes(effort)) { + throw new Error(`Effort must be one of: ${agentEfforts.ALL.join(', ')}.`); + } + return effort; +} + +function schemaOf(value: unknown): Record | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed: unknown = typeof value === 'string' ? parseJson(value) : value; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Output Schema must be a JSON Schema object, e.g. {"type":"object","properties":{...}}.'); + } + return Object.fromEntries(Object.entries(parsed)); +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new Error('Output Schema is not valid JSON.'); + } +} + +function metadataOf(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const entries = Object.entries(value) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => [k, typeof v === 'string' ? v : JSON.stringify(v)]); + return entries.length === 0 ? undefined : Object.fromEntries(entries); +} diff --git a/packages/pieces/community/exa/src/lib/actions/agent/get-agent-run.ts b/packages/pieces/community/exa/src/lib/actions/agent/get-agent-run.ts new file mode 100644 index 000000000000..d0e83c9e5cb5 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/agent/get-agent-run.ts @@ -0,0 +1,39 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { AgentRun, agentRuns } from '../../common/agent'; +import { agentRunOutputSchema } from '../../output-schemas'; + +export const getAgentRunAction = createAction({ + name: 'exa_get_agent_run', + classification: 'READ', + displayName: 'Get Agent Run', + description: + 'Gets the status and result of an Exa Agent run. Use it after Start Agent Run, for example behind a Delay step.', + audience: 'both', + aiMetadata: { + description: + 'Reads one Exa Agent run by id and returns its status (queued, running, completed, failed, cancelled), the text answer, the structured output when a schema was given, flattened citations and the cost. Use to poll a run started with exa_create_agent_run; it is a single read, so call it again while status is queued or running. Read-only and idempotent.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: agentRunOutputSchema, + props: { + runId: Property.ShortText({ + displayName: 'Run ID', + description: + 'The ID returned by Start Agent Run (or List Agent Runs), e.g. "agent_run_01k4d9w6y3h7p2m8".', + required: true, + }), + }, + async run(context) { + const runId = exaInput.requiredId({ value: context.propsValue.runId, name: 'Run ID' }); + const run = await exaApi.call({ + apiKey: context.auth.secret_text, + method: HttpMethod.GET, + path: `/agent/runs/${encodeURIComponent(runId)}`, + }); + return agentRuns.flattenRun(run); + }, +}); diff --git a/packages/pieces/community/exa/src/lib/actions/agent/list-agent-runs.ts b/packages/pieces/community/exa/src/lib/actions/agent/list-agent-runs.ts new file mode 100644 index 000000000000..83fa785a0050 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/agent/list-agent-runs.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { AgentRunList, agentRuns } from '../../common/agent'; +import { agentRunListOutputSchema } from '../../output-schemas'; + +export const listAgentRunsAction = createAction({ + name: 'exa_list_agent_runs', + classification: 'SEARCH', + displayName: 'List Agent Runs', + description: 'Lists your team\'s Exa Agent runs, newest first, one page at a time.', + audience: 'both', + aiMetadata: { + description: + 'Lists the team\'s Exa Agent runs newest first, returning id, status, query, effort and cost per run plus has_more and next_cursor for paging. Use to find an earlier run id when it was not kept; to read one run\'s full result use exa_get_agent_run. Returns one page only, so pass next_cursor as Cursor to continue. Read-only and idempotent.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: agentRunListOutputSchema, + props: { + limit: Property.Number({ + displayName: 'Limit', + description: 'How many runs to return in this page, 1 to 100. Defaults to 20.', + required: false, + defaultValue: 20, + }), + cursor: Property.ShortText({ + displayName: 'Cursor', + description: 'Leave empty for the first page. To get the next page, paste the Next Cursor value from the previous result.', + required: false, + }), + }, + async run(context) { + const limit = exaInput.optionalInteger({ + value: context.propsValue.limit, + name: 'Limit', + min: 1, + max: 100, + }); + const cursor = exaInput.optionalText(context.propsValue.cursor); + const page = await exaApi.call({ + apiKey: context.auth.secret_text, + method: HttpMethod.GET, + path: '/agent/runs', + query: { + ...(limit !== undefined ? { limit: String(limit) } : {}), + ...(cursor ? { cursor } : {}), + }, + }); + const runs = (page.data ?? []).map((run) => agentRuns.summarizeRun(run)); + return { + runs, + count: runs.length, + has_more: page.hasMore ?? false, + next_cursor: page.nextCursor ?? null, + }; + }, +}); diff --git a/packages/pieces/community/exa/src/lib/actions/ai/exa-answer.ts b/packages/pieces/community/exa/src/lib/actions/ai/exa-answer.ts new file mode 100644 index 000000000000..dad4dc199e3a --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/ai/exa-answer.ts @@ -0,0 +1,99 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { ExaCost, ExaResult } from '../../common/results'; +import { exaAnswerOutputSchema } from '../../output-schemas-ai'; + +export const exaAnswerAction = createAction({ + name: 'exa_answer', + classification: 'READ', + displayName: 'Answer a Question', + description: 'Answers a question from live web results and returns the sources it used.', + audience: 'ai', + aiMetadata: { + description: + 'Answers a natural-language question from a live Exa web search and returns the answer text with the cited source pages. Use for a quick factual answer with sources in one call ($0.005); use exa_search to get the pages themselves or exa_create_agent_run for multi-step research. Read-only and idempotent, though wording can vary between calls as web data changes.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: exaAnswerOutputSchema, + props: { + query: Property.LongText({ + displayName: 'Question', + description: 'The question to answer, e.g. "What is the latest stable version of Node.js?".', + required: true, + }), + model: Property.StaticDropdown({ + displayName: 'Model', + description: "'exa' (default), 'exa-pro' for harder questions, or 'exa-fast' for lower latency.", + required: false, + defaultValue: 'exa', + options: { + options: [ + { label: 'Exa', value: 'exa' }, + { label: 'Exa Pro', value: 'exa-pro' }, + { label: 'Exa Fast', value: 'exa-fast' }, + ], + }, + }), + systemPrompt: Property.LongText({ + displayName: 'Instructions', + description: 'Optional guidance for the answer, e.g. "Answer in two sentences and cite official sources only."', + required: false, + }), + userLocation: Property.ShortText({ + displayName: 'User Country', + description: 'Two-letter ISO country code to localize the search, e.g. "US".', + required: false, + }), + includeSourceText: Property.Checkbox({ + displayName: 'Include Source Text', + description: 'Also return the full text of each cited page.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const props = context.propsValue; + const systemPrompt = exaInput.optionalText(props.systemPrompt); + const userLocation = exaInput.optionalCountry({ value: props.userLocation, name: 'User Country' }); + const model = exaInput.optionalText(props.model) ?? 'exa'; + if (!ANSWER_MODELS.includes(model)) { + throw new Error(`Model must be one of: ${ANSWER_MODELS.join(', ')}.`); + } + const response = await exaApi.call<{ answer?: unknown; citations?: ExaResult[]; costDollars?: ExaCost }>({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: '/answer', + body: { + query: props.query, + model, + text: props.includeSourceText === true, + ...(systemPrompt ? { systemPrompt } : {}), + ...(userLocation ? { userLocation } : {}), + }, + }); + return { + answer: answerTextOf(response.answer), + citations: (response.citations ?? []).map((citation) => ({ + id: citation.id ?? null, + title: citation.title ?? null, + url: citation.url ?? null, + published_date: citation.publishedDate ?? null, + author: citation.author ?? null, + text: citation.text ?? null, + })), + cost_total: response.costDollars?.total ?? null, + }; + }, +}); + +function answerTextOf(answer: unknown): string | null { + if (typeof answer === 'string') { + return answer; + } + return answer === undefined || answer === null ? null : JSON.stringify(answer); +} + +const ANSWER_MODELS: readonly string[] = ['exa', 'exa-pro', 'exa-fast']; diff --git a/packages/pieces/community/exa/src/lib/actions/ai/exa-get-contents.ts b/packages/pieces/community/exa/src/lib/actions/ai/exa-get-contents.ts new file mode 100644 index 000000000000..330c42afa231 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/ai/exa-get-contents.ts @@ -0,0 +1,136 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { ExaCost, ExaResult, ExaStatus, exaResults } from '../../common/results'; +import { exaGetContentsOutputSchema } from '../../output-schemas-ai'; + +export const exaGetContentsAction = createAction({ + name: 'exa_get_contents', + classification: 'READ', + displayName: 'Get Page Contents', + description: 'Fetches clean content for known URLs and reports which URLs failed.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches clean page text, and optionally highlights, a question-guided summary, outgoing links and matching subpages, for URLs you already have, and reports a per-URL status so failed fetches are visible (the call still succeeds when some URLs fail). Use after exa_search or when given URLs; use exa_search to discover pages. Billed $0.001 per page per content type. Read-only and idempotent.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: exaGetContentsOutputSchema, + props: { + urls: Property.Array({ + displayName: 'URLs', + description: 'Full URLs to fetch, including https://, e.g. "https://exa.ai/docs". Up to 100.', + required: true, + }), + includeText: Property.Checkbox({ + displayName: 'Include Page Text', + description: 'Return the page text.', + required: false, + defaultValue: true, + }), + textMaxCharacters: Property.Number({ + displayName: 'Max Text Characters', + description: 'Cap the text per page, e.g. 5000. Only used when page text is included.', + required: false, + }), + includeHighlights: Property.Checkbox({ + displayName: 'Include Highlights', + description: 'Return the most relevant snippets from each page.', + required: false, + defaultValue: false, + }), + highlightsQuery: Property.ShortText({ + displayName: 'Highlights Focus', + description: 'Optional question that steers which snippets are picked. Turns highlights on.', + required: false, + }), + summaryQuery: Property.ShortText({ + displayName: 'Summary Question', + description: 'Return a per-page AI summary focused on this question, e.g. "Who are the founders?".', + required: false, + }), + maxAgeHours: Property.Number({ + displayName: 'Max Content Age (hours)', + description: 'Use cached content only if newer than this many hours; 0 always fetches fresh, -1 always uses cache, max 720.', + required: false, + }), + subpages: Property.Number({ + displayName: 'Subpages', + description: 'Also fetch up to this many linked subpages per URL, e.g. 3.', + required: false, + }), + subpageTarget: Property.ShortText({ + displayName: 'Subpage Keyword', + description: 'Prefer subpages matching this keyword, e.g. "pricing" or "about".', + required: false, + }), + linksPerPage: Property.Number({ + displayName: 'Links per Page', + description: 'Also return up to this many links found on each page.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const listed = exaInput.stringList(props.urls); + const urls = listed ? [...new Set(listed)] : undefined; + if (!urls) { + throw new Error('Provide at least one URL.'); + } + if (urls.length > MAX_URLS) { + throw new Error(`Provide at most ${MAX_URLS} URLs per call; ${urls.length} were given.`); + } + const textMaxCharacters = exaInput.optionalInteger({ value: props.textMaxCharacters, name: 'Max Text Characters', min: 1, max: 1000000 }); + const maxAgeHours = exaInput.optionalInteger({ value: props.maxAgeHours, name: 'Max Content Age (hours)', min: -1, max: 720 }); + const subpages = exaInput.optionalInteger({ value: props.subpages, name: 'Subpages', min: 1, max: 100 }); + const linksPerPage = exaInput.optionalInteger({ value: props.linksPerPage, name: 'Links per Page', min: 1, max: 1000 }); + const highlightsQuery = exaInput.optionalText(props.highlightsQuery); + const summaryQuery = exaInput.optionalText(props.summaryQuery); + const subpageTarget = exaInput.optionalText(props.subpageTarget); + const includeText = props.includeText !== false; + if (!includeText && !highlightsQuery && !props.includeHighlights && !summaryQuery && linksPerPage === undefined) { + throw new Error('Choose at least one thing to return: page text, highlights, a summary question or links per page.'); + } + + const response = await exaApi.call<{ results?: ExaResult[]; statuses?: ExaStatus[]; costDollars?: ExaCost }>({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: '/contents', + body: { + urls, + text: includeText + ? textMaxCharacters !== undefined + ? { maxCharacters: textMaxCharacters } + : true + : false, + ...(highlightsQuery + ? { highlights: { query: highlightsQuery } } + : props.includeHighlights + ? { highlights: true } + : {}), + ...(summaryQuery ? { summary: { query: summaryQuery } } : {}), + ...(maxAgeHours !== undefined ? { maxAgeHours } : {}), + ...(subpages !== undefined ? { subpages } : {}), + ...(subpageTarget ? { subpageTarget } : {}), + ...(linksPerPage !== undefined ? { extras: { links: linksPerPage } } : {}), + }, + }); + const statuses = (response.statuses ?? []).map(exaResults.flattenStatus); + const results = (response.results ?? []).map(exaResults.flattenPage); + const fetched = new Set( + statuses.length > 0 + ? statuses.filter((status) => status.status === 'success').map((status) => status.url) + : results.flatMap((result) => [result.id, result.url].filter((value): value is string => value !== null)), + ); + return { + results, + statuses, + failed_count: urls.filter((url) => !fetched.has(url)).length, + cost_total: response.costDollars?.total ?? null, + }; + }, +}); + +const MAX_URLS = 100; diff --git a/packages/pieces/community/exa/src/lib/actions/ai/exa-search.ts b/packages/pieces/community/exa/src/lib/actions/ai/exa-search.ts new file mode 100644 index 000000000000..42e46dc8f514 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/ai/exa-search.ts @@ -0,0 +1,186 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../../auth'; +import { exaApi, exaInput } from '../../common/client'; +import { ExaCost, ExaResult, exaResults } from '../../common/results'; +import { exaSearchOutputSchema } from '../../output-schemas-ai'; + +export const exaSearchAction = createAction({ + name: 'exa_search', + classification: 'SEARCH', + displayName: 'Search the Web', + description: 'Searches the web with Exa and returns flat result rows.', + audience: 'ai', + aiMetadata: { + description: + 'Searches the live web with Exa and returns ranked pages with title, URL, date and optionally page text, highlights or a query-guided summary. Use to discover pages for a topic; use exa_get_contents when the URLs are already known and exa_answer for a direct cited answer. Type sets depth and price (instant $0.004, auto/fast $0.007, deep modes $0.012 to $0.015); the company and people categories reject published-date filters and excludeDomains. Read-only and idempotent.', + idempotent: true, + }, + auth: exaAuth, + outputSchema: exaSearchOutputSchema, + props: { + query: Property.LongText({ + displayName: 'Query', + description: 'What to search for, in natural language, e.g. "latest research on solid-state batteries".', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Search Type', + description: "One of 'auto' (default), 'fast', 'instant', 'deep-lite', 'deep', 'deep-reasoning'. Deep modes are slower and cost more.", + required: false, + defaultValue: 'auto', + options: { + options: [ + { label: 'Auto', value: 'auto' }, + { label: 'Fast', value: 'fast' }, + { label: 'Instant', value: 'instant' }, + { label: 'Deep Lite', value: 'deep-lite' }, + { label: 'Deep', value: 'deep' }, + { label: 'Deep Reasoning', value: 'deep-reasoning' }, + ], + }, + }), + category: Property.StaticDropdown({ + displayName: 'Category', + description: "Optional focus: 'company', 'people', 'news', 'publication', 'personal site' or 'financial report'.", + required: false, + options: { + options: [ + { label: 'Company', value: 'company' }, + { label: 'People', value: 'people' }, + { label: 'News', value: 'news' }, + { label: 'Publication', value: 'publication' }, + { label: 'Personal Site', value: 'personal site' }, + { label: 'Financial Report', value: 'financial report' }, + ], + }, + }), + numResults: Property.Number({ + displayName: 'Number of Results', + description: 'How many results to return, 1 to 100. Defaults to 10; each result above 10 costs $0.001.', + required: false, + defaultValue: 10, + }), + includeDomains: Property.Array({ + displayName: 'Include Domains', + description: 'Only return results from these domains or paths, e.g. "arxiv.org" or "example.com/blog".', + required: false, + }), + excludeDomains: Property.Array({ + displayName: 'Exclude Domains', + description: 'Never return results from these domains. Not allowed with the company or people category.', + required: false, + }), + startPublishedDate: Property.DateTime({ + displayName: 'Published After', + description: 'Only results published after this ISO 8601 date, e.g. "2026-01-01T00:00:00Z". Not allowed with the company or people category.', + required: false, + }), + endPublishedDate: Property.DateTime({ + displayName: 'Published Before', + description: 'Only results published before this ISO 8601 date. Not allowed with the company or people category.', + required: false, + }), + includeText: Property.Checkbox({ + displayName: 'Include Page Text', + description: 'Return the page text for each result.', + required: false, + defaultValue: true, + }), + textMaxCharacters: Property.Number({ + displayName: 'Max Text Characters', + description: 'Cap the page text per result, e.g. 2000. Only used when page text is included.', + required: false, + }), + includeHighlights: Property.Checkbox({ + displayName: 'Include Highlights', + description: 'Return the most relevant snippets from each page.', + required: false, + defaultValue: false, + }), + highlightsQuery: Property.ShortText({ + displayName: 'Highlights Focus', + description: 'Optional question that steers which snippets are picked. Turns highlights on.', + required: false, + }), + summaryQuery: Property.ShortText({ + displayName: 'Summary Question', + description: 'Return a per-page AI summary focused on this question, e.g. "What does this company sell?" ($0.001 per page).', + required: false, + }), + maxAgeHours: Property.Number({ + displayName: 'Max Content Age (hours)', + description: 'Use cached content only if newer than this many hours; 0 always fetches fresh, -1 always uses cache, max 720.', + required: false, + }), + userLocation: Property.ShortText({ + displayName: 'User Country', + description: 'Two-letter ISO country code to localize results, e.g. "US".', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const numResults = exaInput.optionalInteger({ value: props.numResults, name: 'Number of Results', min: 1, max: 100 }); + const textMaxCharacters = exaInput.optionalInteger({ value: props.textMaxCharacters, name: 'Max Text Characters', min: 1, max: 1000000 }); + const maxAgeHours = exaInput.optionalInteger({ value: props.maxAgeHours, name: 'Max Content Age (hours)', min: -1, max: 720 }); + const includeDomains = exaInput.stringList(props.includeDomains); + const excludeDomains = exaInput.stringList(props.excludeDomains); + const type = exaInput.optionalText(props.type) ?? 'auto'; + if (!SEARCH_TYPES.includes(type)) { + throw new Error(`Search Type must be one of: ${SEARCH_TYPES.join(', ')}.`); + } + const category = exaInput.optionalText(props.category); + const highlightsQuery = exaInput.optionalText(props.highlightsQuery); + const summaryQuery = exaInput.optionalText(props.summaryQuery); + const userLocation = exaInput.optionalCountry({ value: props.userLocation, name: 'User Country' }); + const startPublishedDate = exaInput.optionalText(props.startPublishedDate); + const endPublishedDate = exaInput.optionalText(props.endPublishedDate); + + if (category === 'company' || category === 'people') { + if (startPublishedDate || endPublishedDate || excludeDomains) { + throw new Error(`The ${category} category does not support published-date filters or Exclude Domains.`); + } + } + + const includeText = props.includeText !== false; + const contents = { + ...(includeText + ? { text: textMaxCharacters !== undefined ? { maxCharacters: textMaxCharacters } : true } + : {}), + ...(highlightsQuery + ? { highlights: { query: highlightsQuery } } + : props.includeHighlights + ? { highlights: true } + : {}), + ...(summaryQuery ? { summary: { query: summaryQuery } } : {}), + ...(maxAgeHours !== undefined ? { maxAgeHours } : {}), + }; + + const response = await exaApi.call<{ results?: ExaResult[]; costDollars?: ExaCost }>({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: '/search', + body: { + query: props.query, + type, + ...(category ? { category } : {}), + ...(numResults !== undefined ? { numResults } : {}), + ...(includeDomains ? { includeDomains } : {}), + ...(excludeDomains ? { excludeDomains } : {}), + ...(startPublishedDate ? { startPublishedDate } : {}), + ...(endPublishedDate ? { endPublishedDate } : {}), + ...(userLocation ? { userLocation } : {}), + ...(Object.keys(contents).length > 0 ? { contents } : {}), + }, + }); + const results = (response.results ?? []).map(exaResults.flattenResult); + return { + results, + count: results.length, + cost_total: response.costDollars?.total ?? null, + }; + }, +}); + +const SEARCH_TYPES: readonly string[] = ['auto', 'fast', 'instant', 'deep-lite', 'deep', 'deep-reasoning']; diff --git a/packages/pieces/community/exa/src/lib/actions/custom-api-call.ts b/packages/pieces/community/exa/src/lib/actions/custom-api-call.ts new file mode 100644 index 000000000000..4740139be60d --- /dev/null +++ b/packages/pieces/community/exa/src/lib/actions/custom-api-call.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { exaAuth } from '../auth'; +import { BASE_URL, exaApi } from '../common/client'; + +const baseAction = createCustomApiCallAction({ + auth: exaAuth, + baseUrl: () => BASE_URL, + authMapping: async (auth, propsValue) => { + exaApi.assertExaUrl(propsValue['url']?.['url']); + if (propsValue['followRedirects'] === true) { + throw new Error('Follow redirects is not supported for Exa: a redirect could carry your API key to another host.'); + } + return { 'x-api-key': auth.secret_text }; + }, +}); + +export const exaCustomApiCallAction = createAction({ + auth: exaAuth, + name: 'custom_api_call', + displayName: 'Custom API Call', + description: 'Make a custom API call to a specific endpoint', + props: baseAction.props, + requireAuth: baseAction.requireAuth, + errorHandlingOptions: baseAction.errorHandlingOptions, + audience: 'human', + classification: 'WRITE', + aiMetadata: { + description: + 'Sends an authenticated request to any Exa API path on https://api.exa.ai, for endpoints the other Exa actions do not cover. Takes a relative path or a full api.exa.ai URL, a method, headers, query parameters and an optional body, and returns the HTTP status, headers and body. Not idempotent in general: POST and DELETE change Exa state.', + idempotent: false, + }, + run: async (context) => + baseAction.run({ + ...context, + propsValue: { ...context.propsValue, followRedirects: context.propsValue.followRedirects ?? false }, + }), +}); diff --git a/packages/pieces/community/exa/src/lib/actions/find-similar-links.ts b/packages/pieces/community/exa/src/lib/actions/find-similar-links.ts index a380d9adb1c3..778d4215fc2b 100644 --- a/packages/pieces/community/exa/src/lib/actions/find-similar-links.ts +++ b/packages/pieces/community/exa/src/lib/actions/find-similar-links.ts @@ -2,18 +2,20 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { makeRequest } from '../common'; import { exaAuth } from '../auth'; +import { similarLinksOutputSchema } from '../output-schemas'; export const findSimilarLinksAction = createAction({ name: 'find_similar_links', classification: 'SEARCH', displayName: 'Find Similar Links', description: 'Find pages similar to a given URL.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Finds web pages semantically similar to a single reference URL via Exa. Use when an agent has one example page and wants more of the same kind rather than searching by a text query; results can be narrowed by domain include/exclude lists, crawl/publish date ranges, and required/forbidden text. Requires a reference URL. Read-only and idempotent.', idempotent: true, }, auth: exaAuth, + outputSchema: similarLinksOutputSchema, props: { url: Property.ShortText({ displayName: 'URL', diff --git a/packages/pieces/community/exa/src/lib/actions/generate-answer.ts b/packages/pieces/community/exa/src/lib/actions/generate-answer.ts index 936368edcb92..3afb961fd505 100644 --- a/packages/pieces/community/exa/src/lib/actions/generate-answer.ts +++ b/packages/pieces/community/exa/src/lib/actions/generate-answer.ts @@ -2,15 +2,16 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { makeRequest } from '../common'; import { exaAuth } from '../auth'; +import { exaInput } from '../common/client'; export const generateAnswerAction = createAction({ name: 'generate_answer', classification: 'READ', displayName: 'Ask AI', description: 'Provides direct answers to queries by summarizing results.', - audience: 'both', + audience: 'human', aiMetadata: { - description: 'Asks Exa a natural-language question and returns a synthesized answer grounded in live web search results. Use when an agent wants a direct factual answer rather than a list of links to triage itself; choose the exa or exa-pro model. Requires a query string. Read-only lookup; repeating the same query is safe though phrasing and live web data may vary the wording.', + description: 'Asks Exa a natural-language question and returns a synthesized answer grounded in live web search results. Use when an agent wants a direct factual answer rather than a list of links to triage itself; choose the exa, exa-pro or exa-fast model and optionally add instructions or a country. Requires a query string. Read-only lookup; repeating the same query is safe though phrasing and live web data may vary the wording.', idempotent: true, }, auth: exaAuth, @@ -34,10 +35,21 @@ export const generateAnswerAction = createAction({ options: [ { label: 'Exa', value: 'exa' }, { label: 'Exa Pro', value: 'exa-pro' }, + { label: 'Exa Fast', value: 'exa-fast' }, ], }, defaultValue: 'exa', }), + systemPrompt: Property.LongText({ + displayName: 'Instructions', + description: 'Extra guidance for the answer, such as tone, length or preferred sources, e.g. "Answer in two sentences and prefer official sources."', + required: false, + }), + userLocation: Property.ShortText({ + displayName: 'User Country', + description: 'Two-letter country code to localize the search behind the answer, e.g. "US".', + required: false, + }), }, async run(context) { const apiKey = context.auth.secret_text; @@ -48,10 +60,15 @@ export const generateAnswerAction = createAction({ model, } = context.propsValue; + const systemPrompt = exaInput.optionalText(context.propsValue.systemPrompt); + const userLocation = exaInput.optionalCountry({ value: context.propsValue.userLocation, name: 'User Country' }); + const body: Record = { query, text, - model + model, + ...(systemPrompt ? { systemPrompt } : {}), + ...(userLocation ? { userLocation } : {}), }; diff --git a/packages/pieces/community/exa/src/lib/actions/get-contents.ts b/packages/pieces/community/exa/src/lib/actions/get-contents.ts index cc2c429a2343..65884d70aa33 100644 --- a/packages/pieces/community/exa/src/lib/actions/get-contents.ts +++ b/packages/pieces/community/exa/src/lib/actions/get-contents.ts @@ -2,18 +2,21 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { makeRequest } from '../common'; import { exaAuth } from '../auth'; +import { exaInput } from '../common/client'; +import { contentsResultsOutputSchema } from '../output-schemas'; export const getContentsAction = createAction({ name: 'get_contents', classification: 'READ', displayName: 'Get Contents', description: 'Retrieve clean HTML content from specified URLs.', - audience: 'both', + audience: 'human', aiMetadata: { - description: 'Crawls and extracts clean page content (and optionally full text) from a given list of URLs via Exa. Use when an agent already has specific URLs and needs their on-page content rather than running a web search; optionally fetch matching subpages by keyword. Requires an array of URLs as input. Read-only and idempotent.', + description: 'Crawls and extracts clean page content (and optionally full text, highlights, a guided summary and outgoing links) from a given list of URLs via Exa. Use when an agent already has specific URLs and needs their on-page content rather than running a web search; optionally fetch matching subpages by keyword and control cache freshness. Requires an array of URLs as input. Read-only and idempotent.', idempotent: true, }, auth: exaAuth, + outputSchema: contentsResultsOutputSchema, props: { urls: Property.Array({ displayName: 'URLs', @@ -28,7 +31,7 @@ export const getContentsAction = createAction({ }), livecrawl: Property.StaticDropdown({ displayName: 'Livecrawl Option', - description: 'Options for livecrawling pages.', + description: 'Deprecated by Exa: prefer Max Content Age. Ignored when Max Content Age is set.', required: false, options: { options: [ @@ -54,6 +57,27 @@ export const getContentsAction = createAction({ description: 'Keyword(s) to find specific subpages.', required: false, }), + highlights: Property.Checkbox({ + displayName: 'Include Highlights', + description: 'Also return the most relevant snippets from each page (billed as an extra content type).', + required: false, + defaultValue: false, + }), + summaryQuery: Property.ShortText({ + displayName: 'Summary Question', + description: 'Ask for an AI summary of each page focused on this question, e.g. "What does this company sell?" (billed per page).', + required: false, + }), + maxAgeHours: Property.Number({ + displayName: 'Max Content Age (hours)', + description: 'Use cached content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Replaces Livecrawl Option.', + required: false, + }), + extrasLinks: Property.Number({ + displayName: 'Links per Page', + description: 'Also return up to this many links found on each page, e.g. 10.', + required: false, + }), }, async run(context) { const apiKey = context.auth.secret_text; @@ -62,8 +86,26 @@ export const getContentsAction = createAction({ urls: context.propsValue.urls, }; + const maxAgeHours = exaInput.optionalInteger({ + value: context.propsValue.maxAgeHours, + name: 'Max Content Age (hours)', + min: -1, + max: 720, + }); + const extrasLinks = exaInput.optionalInteger({ + value: context.propsValue.extrasLinks, + name: 'Links per Page', + min: 1, + max: 1000, + }); + const summaryQuery = exaInput.optionalText(context.propsValue.summaryQuery); + if (context.propsValue.text !== undefined) body['text'] = context.propsValue.text; - if (context.propsValue.livecrawl) body['livecrawl'] = context.propsValue.livecrawl; + if (maxAgeHours !== undefined) body['maxAgeHours'] = maxAgeHours; + else if (context.propsValue.livecrawl) body['livecrawl'] = context.propsValue.livecrawl; + if (context.propsValue.highlights) body['highlights'] = true; + if (summaryQuery) body['summary'] = { query: summaryQuery }; + if (extrasLinks !== undefined) body['extras'] = { links: extrasLinks }; if (context.propsValue.livecrawlTimeout !== undefined) body['livecrawlTimeout'] = context.propsValue.livecrawlTimeout; if (context.propsValue.subpages !== undefined) body['subpages'] = context.propsValue.subpages; if (context.propsValue.subpageTarget) body['subpageTarget'] = context.propsValue.subpageTarget; diff --git a/packages/pieces/community/exa/src/lib/actions/perform-search.ts b/packages/pieces/community/exa/src/lib/actions/perform-search.ts index a8384478f503..5e8698e57a5e 100644 --- a/packages/pieces/community/exa/src/lib/actions/perform-search.ts +++ b/packages/pieces/community/exa/src/lib/actions/perform-search.ts @@ -2,18 +2,21 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; import { makeRequest } from '../common'; import { exaAuth } from '../auth'; +import { exaInput } from '../common/client'; +import { searchResultsOutputSchema } from '../output-schemas'; export const performSearchAction = createAction({ name: 'perform_search', classification: 'SEARCH', displayName: 'Perform Search', description: "Search the web using semantic or keyword-based search.", - audience: 'both', + audience: 'human', aiMetadata: { - description: 'Runs a web search through Exa and returns matching results with page text. Use when an agent needs to discover relevant URLs/content for a query; the search type can be auto, neural (semantic), or keyword, and results can be narrowed by category, domain include/exclude lists, crawl/publish date ranges, and required/forbidden text. Requires a query string. Read-only and idempotent.', + description: 'Runs a web search through Exa and returns matching results with page text. Use when an agent needs to discover relevant URLs/content for a query; the search type ranges from instant and fast to auto and the deep research modes, and results can be narrowed by category, domain include/exclude lists, publish date range, and country, optionally adding highlights. Requires a query string. Read-only and idempotent.', idempotent: true, }, auth: exaAuth, + outputSchema: searchResultsOutputSchema, props: { query: Property.ShortText({ displayName: 'Query', @@ -22,20 +25,25 @@ export const performSearchAction = createAction({ }), type: Property.StaticDropdown({ displayName: 'Search Type', - description: 'Type of search to perform.', + description: 'Type of search to perform. Auto suits most searches; Instant and Fast trade depth for speed; Deep Lite, Deep and Deep Reasoning research more and cost more ($0.012 to $0.015 per search). Keyword and Neural are legacy options that Exa no longer documents.', required: false, defaultValue: 'auto', options: { options: [ { label: 'Auto', value: 'auto' }, - { label: 'Keyword', value: 'keyword' }, - { label: 'Neural', value: 'neural' }, + { label: 'Keyword (legacy)', value: 'keyword' }, + { label: 'Neural (legacy)', value: 'neural' }, + { label: 'Instant', value: 'instant' }, + { label: 'Fast', value: 'fast' }, + { label: 'Deep Lite', value: 'deep-lite' }, + { label: 'Deep', value: 'deep' }, + { label: 'Deep Reasoning', value: 'deep-reasoning' }, ], }, }), category: Property.StaticDropdown({ displayName: 'Category', - description: 'Category of data to focus the search on.', + description: 'Category of data to focus the search on. Company and People do not support published-date filters or Exclude Domains.', required: false, options: { options: [ @@ -48,6 +56,8 @@ export const performSearchAction = createAction({ { label: 'Personal Site', value: 'personal site' }, { label: 'LinkedIn Profile', value: 'linkedin profile' }, { label: 'Financial Report', value: 'financial report' }, + { label: 'Publication', value: 'publication' }, + { label: 'People', value: 'people' }, ], }, }), @@ -69,12 +79,12 @@ export const performSearchAction = createAction({ }), startCrawlDate: Property.DateTime({ displayName: 'Start Crawl Date', - description: 'Only include results crawled after this ISO date.', + description: 'Deprecated: Exa now ignores this filter. Use the published date filters instead.', required: false, }), endCrawlDate: Property.DateTime({ displayName: 'End Crawl Date', - description: 'Only include results crawled before this ISO date.', + description: 'Deprecated: Exa now ignores this filter. Use the published date filters instead.', required: false, }), startPublishedDate: Property.DateTime({ @@ -89,23 +99,68 @@ export const performSearchAction = createAction({ }), includeText: Property.Array({ displayName: 'Include Text', - description: 'Strings that must be present in the text of results.', + description: 'Deprecated by Exa. Strings that must be present in the text of results.', required: false, }), excludeText: Property.Array({ displayName: 'Exclude Text', - description: 'Strings that must not be present in the text of results.', + description: 'Deprecated by Exa. Strings that must not be present in the text of results.', required: false, }), + textMaxCharacters: Property.Number({ + displayName: 'Max Text Characters', + description: 'Limit the page text returned per result to this many characters, e.g. 2000. Leave empty for full text.', + required: false, + }), + highlights: Property.Checkbox({ + displayName: 'Include Highlights', + description: 'Also return the most relevant snippets from each page.', + required: false, + defaultValue: false, + }), + maxAgeHours: Property.Number({ + displayName: 'Max Content Age (hours)', + description: 'Use cached page content only if it is newer than this many hours (0 always fetches fresh, -1 always uses cache, up to 720). Leave empty for Exa\'s default.', + required: false, + }), + userLocation: Property.ShortText({ + displayName: 'User Country', + description: 'Two-letter country code to localize results, e.g. "US" or "DE".', + required: false, + }), + moderation: Property.Checkbox({ + displayName: 'Filter Unsafe Content', + description: 'Remove unsafe content from the results.', + required: false, + defaultValue: false, + }), }, async run(context) { const apiKey = context.auth.secret_text; + const textMaxCharacters = exaInput.optionalInteger({ + value: context.propsValue.textMaxCharacters, + name: 'Max Text Characters', + min: 1, + max: 1000000, + }); + const maxAgeHours = exaInput.optionalInteger({ + value: context.propsValue.maxAgeHours, + name: 'Max Content Age (hours)', + min: -1, + max: 720, + }); + const userLocation = exaInput.optionalCountry({ value: context.propsValue.userLocation, name: 'User Country' }); + const body: Record = { query: context.propsValue.query, - contents:{ - text:true - } + contents: { + text: textMaxCharacters !== undefined ? { maxCharacters: textMaxCharacters } : true, + ...(context.propsValue.highlights ? { highlights: true } : {}), + ...(maxAgeHours !== undefined ? { maxAgeHours } : {}), + }, + ...(userLocation ? { userLocation } : {}), + ...(context.propsValue.moderation ? { moderation: true } : {}), }; const optionalProps = [ diff --git a/packages/pieces/community/exa/src/lib/auth.ts b/packages/pieces/community/exa/src/lib/auth.ts index c1296713086a..93516ad51526 100644 --- a/packages/pieces/community/exa/src/lib/auth.ts +++ b/packages/pieces/community/exa/src/lib/auth.ts @@ -1,6 +1,6 @@ import { PieceAuth } from '@activepieces/pieces-framework'; import { HttpMethod } from '@activepieces/pieces-common'; -import { makeRequest } from './common'; +import { exaApi } from './common/client'; const markdownDescription = ` Obtain your API key from [Dashboard Setting](https://dashboard.exa.ai/api-keys). @@ -10,23 +10,24 @@ export const exaAuth = PieceAuth.SecretText({ displayName: 'API Key', description: markdownDescription, required: true, - validate:async ({auth})=>{ - try - { - await makeRequest(auth,HttpMethod.POST, - '/search',{query:'Activepieces'} - ) - - return{ - valid:true - } - - }catch(e) - { - return{ - valid:false, - error:'Invalid API Key.' + validate: async ({ auth }) => { + try { + await exaApi.call({ + apiKey: auth, + method: HttpMethod.GET, + path: '/agent/runs', + query: { limit: '1' }, + }); + return { valid: true }; + } catch (e) { + const status = exaApi.statusOf(e); + if (status === 401 || status === 403) { + return { valid: false, error: 'Invalid API Key.' }; } + return { + valid: false, + error: `Could not verify the API key with Exa${status ? ` (HTTP ${status})` : ''}. Try again in a moment.`, + }; } - } + }, }); diff --git a/packages/pieces/community/exa/src/lib/common/agent.ts b/packages/pieces/community/exa/src/lib/common/agent.ts new file mode 100644 index 000000000000..4b76aaabfee1 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/common/agent.ts @@ -0,0 +1,117 @@ +export const agentEfforts = { + ALL: ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'ultra'], + METERED: ['auto', 'ultra'], + DEFAULT: 'low', + OPTIONS: [ + { label: 'Minimal ($0.012 per run)', value: 'minimal' }, + { label: 'Low ($0.025 per run)', value: 'low' }, + { label: 'Medium ($0.10 per run)', value: 'medium' }, + { label: 'High ($0.50 per run)', value: 'high' }, + { label: 'Extra High ($1.00 per run)', value: 'xhigh' }, + { label: 'Auto (metered, up to $5 by default)', value: 'auto' }, + { label: 'Ultra (metered, up to $20 by default)', value: 'ultra' }, + ], +}; + +export const agentRuns = { flattenRun, summarizeRun }; + +function flattenRun(run: AgentRun): FlatAgentRun { + return { + id: run.id, + status: run.status, + stop_reason: run.stopReason ?? null, + created_at: run.createdAt ?? null, + completed_at: run.completedAt ?? null, + query: run.request?.query ?? null, + effort: run.request?.effort ?? null, + output_text: run.output?.text ?? null, + output_structured: run.output?.structured ?? null, + citations: citationsOf(run.output?.grounding), + search_count: run.usage?.searches ?? null, + agent_compute_units: run.usage?.agentComputeUnits ?? null, + cost_total: run.costDollars?.total ?? null, + }; +} + +function summarizeRun(run: AgentRun): AgentRunSummary { + return { + id: run.id, + status: run.status, + stop_reason: run.stopReason ?? null, + created_at: run.createdAt ?? null, + completed_at: run.completedAt ?? null, + query: run.request?.query ?? null, + effort: run.request?.effort ?? null, + cost_total: run.costDollars?.total ?? null, + }; +} + +function citationsOf(grounding: AgentGrounding[] | undefined): FlatCitation[] { + if (!Array.isArray(grounding)) { + return []; + } + return grounding.flatMap((entry) => + (entry.citations ?? []).map((citation) => ({ + field: entry.field ?? null, + url: citation.url, + title: citation.title ?? null, + confidence: entry.confidence ?? null, + })), + ); +} + +export type AgentGrounding = { + field?: string; + citations?: { url: string; title?: string }[]; + confidence?: string | null; +}; + +export type AgentRun = { + id: string; + object?: string; + status: string; + stopReason?: string | null; + createdAt?: string; + completedAt?: string | null; + request?: { query?: string; effort?: string } | null; + output?: { + text?: string; + structured?: unknown; + grounding?: AgentGrounding[]; + }; + usage?: { agentComputeUnits?: number; searches?: number }; + costDollars?: { total?: number }; +}; + +export type AgentRunList = { + object?: string; + data: AgentRun[]; + hasMore?: boolean; + nextCursor?: string | null; +}; + +export type FlatCitation = { + field: string | null; + url: string; + title: string | null; + confidence: string | null; +}; + +export type AgentRunSummary = { + id: string; + status: string; + stop_reason: string | null; + created_at: string | null; + completed_at: string | null; + query: string | null; + effort: string | null; + cost_total: number | null; +}; + +export type FlatAgentRun = AgentRunSummary & { + output_text: string | null; + output_structured: unknown; + citations: FlatCitation[]; + search_count: number | null; + agent_compute_units: number | null; +}; diff --git a/packages/pieces/community/exa/src/lib/common/client.ts b/packages/pieces/community/exa/src/lib/common/client.ts new file mode 100644 index 000000000000..a6e33e6dde2e --- /dev/null +++ b/packages/pieces/community/exa/src/lib/common/client.ts @@ -0,0 +1,254 @@ +import { + HttpMethod, + QueryParams, + httpClient, +} from '@activepieces/pieces-common'; + +export const BASE_URL = 'https://api.exa.ai'; + +export const exaApi = { call, statusOf, assertExaUrl, toError, bodyOf }; + +export const exaInput = { + optionalInteger, + optionalNumber, + optionalText, + optionalCountry, + requiredId, + stringList, +}; + +async function call({ + apiKey, + method, + path, + body, + query, +}: { + apiKey: string; + method: HttpMethod; + path: string; + body?: unknown; + query?: QueryParams; +}): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${BASE_URL}${path}`, + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + queryParams: query, + body, + followRedirects: false, + }); + return bodyOf(response); + } catch (error) { + throw toError(error); + } +} + +function bodyOf(response: { status: number; body: T }): T { + if (response.status >= 300) { + throw new ExaApiError({ + message: `Exa answered with a redirect (${response.status}), which is not followed so the API key stays on ${BASE_URL}.`, + status: response.status, + }); + } + return response.body; +} + +function toError(error: unknown): ExaApiError { + if (error instanceof ExaApiError) { + return error; + } + return new ExaApiError({ + message: errorMessageOf(error), + status: responseOf(error)?.status, + }); +} + +function statusOf(error: unknown): number | undefined { + if (typeof error === 'object' && error !== null) { + const status: unknown = Reflect.get(error, 'status'); + if (typeof status === 'number') { + return status; + } + } + return responseOf(error)?.status; +} + +function assertExaUrl(url: unknown): void { + if (typeof url !== 'string' || !/^https?:\/\//i.test(url.trim())) { + return; + } + const parsed = parseUrl(url.trim()); + const allowed = new URL(BASE_URL); + if ( + parsed === undefined || + parsed.protocol !== allowed.protocol || + parsed.hostname !== allowed.hostname || + parsed.port !== '' || + parsed.username !== '' || + parsed.password !== '' + ) { + throw new Error( + `Custom API Call only sends your Exa API key to ${BASE_URL}. Use a path such as /search, or the HTTP piece for other hosts.`, + ); + } +} + +function optionalInteger({ + value, + name, + min, + max, +}: { + value: unknown; + name: string; + min: number; + max: number; +}): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be a whole number between ${min} and ${max}.`); + } + return parsed; +} + +function optionalNumber({ + value, + name, + min, + max, +}: { + value: unknown; + name: string; + min: number; + max: number; +}): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be a number between ${min} and ${max}.`); + } + return parsed; +} + +function optionalText(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function optionalCountry({ value, name }: { value: unknown; name: string }): string | undefined { + const text = optionalText(value); + if (text === undefined) { + return undefined; + } + if (!/^[A-Za-z]{2}$/.test(text)) { + throw new Error(`${name} must be a two-letter country code, e.g. "US".`); + } + return text.toUpperCase(); +} + +function requiredId({ value, name }: { value: unknown; name: string }): string { + const id = optionalText(value); + if (id === undefined) { + throw new Error(`${name} is required.`); + } + return id; +} + +function stringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const items = value + .map((item) => (typeof item === 'string' ? item : String(item ?? '')).trim()) + .filter((item) => item.length > 0); + return items.length === 0 ? undefined : items; +} + +function parseUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + return undefined; + } +} + +function responseOf(error: unknown): { status: number; body: unknown } | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const response: unknown = Reflect.get(error, 'response'); + if (typeof response !== 'object' || response === null) { + return undefined; + } + const status: unknown = Reflect.get(response, 'status'); + if (typeof status !== 'number') { + return undefined; + } + return { status, body: Reflect.get(response, 'body') }; +} + +function errorMessageOf(error: unknown): string { + const response = responseOf(error); + if (response === undefined) { + return error instanceof Error ? error.message : String(error); + } + const detail = detailOf(response.body); + const suffix = detail ? `: ${detail}` : ''; + switch (response.status) { + case 401: + return `Exa rejected the API key (401)${suffix}. Check the key in your Exa connection.`; + case 402: + return `Exa reports insufficient credits (402)${suffix}. Add credits in the Exa dashboard.`; + case 403: + return `Exa refused the request (403)${suffix}.`; + case 404: + return `Exa could not find that resource (404)${suffix}. Check the ID.`; + case 429: + return `Exa rate limit or concurrency limit reached (429)${suffix}. Wait and try again.`; + default: + return `Exa request failed (${response.status})${suffix}`; + } +} + +function detailOf(body: unknown): string | undefined { + if (typeof body === 'string') { + return body.slice(0, 500); + } + if (typeof body !== 'object' || body === null) { + return undefined; + } + const err: unknown = Reflect.get(body, 'error'); + if (typeof err === 'string') { + return err; + } + if (typeof err === 'object' && err !== null) { + const message: unknown = Reflect.get(err, 'message'); + const code: unknown = Reflect.get(err, 'code'); + if (typeof message === 'string') { + return typeof code === 'string' ? `${code} ${message}` : message; + } + } + return undefined; +} + +class ExaApiError extends Error { + readonly status: number | undefined; + constructor({ message, status }: { message: string; status: number | undefined }) { + super(message); + this.name = 'ExaApiError'; + this.status = status; + } +} diff --git a/packages/pieces/community/exa/src/lib/common/index.ts b/packages/pieces/community/exa/src/lib/common/index.ts index 7e2d2d4f24dc..eb1e8751675b 100644 --- a/packages/pieces/community/exa/src/lib/common/index.ts +++ b/packages/pieces/community/exa/src/lib/common/index.ts @@ -1,17 +1,21 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; - -export const BASE_URL = 'https://api.exa.ai'; +import { BASE_URL, exaApi } from './client'; export async function makeRequest(auth: string, method: HttpMethod, path: string, body?: unknown) { - const response = await httpClient.sendRequest({ - method, - url: `${BASE_URL}${path}`, - headers: { - 'x-api-key': `${auth}`, - 'Content-Type': 'application/json', - }, - body, - }); + try { + const response = await httpClient.sendRequest({ + method, + url: `${BASE_URL}${path}`, + headers: { + 'x-api-key': `${auth}`, + 'Content-Type': 'application/json', + }, + body, + followRedirects: false, + }); - return response.body; + return exaApi.bodyOf(response); + } catch (error) { + throw exaApi.toError(error); + } } diff --git a/packages/pieces/community/exa/src/lib/common/results.ts b/packages/pieces/community/exa/src/lib/common/results.ts new file mode 100644 index 000000000000..a2e0813afdc8 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/common/results.ts @@ -0,0 +1,81 @@ +export const exaResults = { flattenResult, flattenPage, flattenStatus }; + +function flattenResult(result: ExaResult): FlatResult { + return { + id: result.id ?? null, + title: result.title ?? null, + url: result.url ?? null, + published_date: result.publishedDate ?? null, + author: result.author ?? null, + text: result.text ?? null, + highlights: Array.isArray(result.highlights) ? result.highlights.join('\n') : null, + summary: result.summary ?? null, + image: result.image ?? null, + }; +} + +function flattenPage(result: ExaResult): FlatPage { + return { + ...flattenResult(result), + links: Array.isArray(result.extras?.links) ? result.extras.links : null, + subpages: Array.isArray(result.subpages) ? result.subpages.map(flattenResult) : [], + }; +} + +function flattenStatus(status: ExaStatus): FlatStatus { + return { + url: status.id, + status: status.status, + source: status.source ?? null, + error_tag: status.error?.tag ?? null, + error_http_status: status.error?.httpStatusCode ?? null, + }; +} + +export type ExaResult = { + id?: string; + title?: string | null; + url?: string; + publishedDate?: string | null; + author?: string | null; + text?: string; + highlights?: string[]; + summary?: string; + image?: string; + extras?: { links?: string[] }; + subpages?: ExaResult[]; +}; + +export type ExaStatus = { + id: string; + status: string; + source?: string; + error?: { tag?: string; httpStatusCode?: number | null } | null; +}; + +export type ExaCost = { total?: number }; + +export type FlatResult = { + id: string | null; + title: string | null; + url: string | null; + published_date: string | null; + author: string | null; + text: string | null; + highlights: string | null; + summary: string | null; + image: string | null; +}; + +export type FlatPage = FlatResult & { + links: string[] | null; + subpages: FlatResult[]; +}; + +export type FlatStatus = { + url: string; + status: string; + source: string | null; + error_tag: string | null; + error_http_status: number | null; +}; diff --git a/packages/pieces/community/exa/src/lib/common/webhook-signature.ts b/packages/pieces/community/exa/src/lib/common/webhook-signature.ts new file mode 100644 index 000000000000..5ad020cf4a07 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/common/webhook-signature.ts @@ -0,0 +1,103 @@ +import crypto from 'crypto'; + +export const exaWebhookSignature = { HEADER: 'exa-signature', verify, problemOf, headerOf }; + +function verify(params: SignatureCheck): boolean { + return problemOf(params) === undefined; +} + +function problemOf({ secret, signatureHeader, rawBody, nowMs = Date.now() }: SignatureCheck): string | undefined { + if (!secret) { + return 'no webhook secret is stored for this flow'; + } + if (!signatureHeader) { + return 'the Exa-Signature header is missing'; + } + const payload = bytesOf(rawBody); + if (payload === undefined) { + return 'the raw request body is not available'; + } + const parts = partsOf(signatureHeader); + const timestamp = parts.get('t'); + const signatures = parts.getAll('v1'); + if (!timestamp || signatures.length === 0) { + return 'the Exa-Signature header is malformed'; + } + const expected = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.`) + .update(payload) + .digest(); + if (!signatures.some((signature) => matches({ expected, signature }))) { + return 'the signature does not match'; + } + if (!isRecent({ timestamp, nowMs })) { + return `it was signed more than 15 minutes ago (t=${timestamp})`; + } + return undefined; +} + +function isRecent({ timestamp, nowMs }: { timestamp: string; nowMs: number }): boolean { + if (!/^\d+$/.test(timestamp)) { + return false; + } + const value = Number(timestamp); + const sentSeconds = value > MILLISECOND_TIMESTAMP_FLOOR ? value / 1000 : value; + return Math.abs(nowMs / 1000 - sentSeconds) <= TIMESTAMP_TOLERANCE_SECONDS; +} + +function headerOf({ + headers, + name, +}: { + headers: Record | undefined; + name: string; +}): string | undefined { + if (!headers) { + return undefined; + } + const key = Object.keys(headers).find((header) => header.toLowerCase() === name); + return key === undefined ? undefined : headers[key]; +} + +function bytesOf(rawBody: unknown): Buffer | undefined { + if (Buffer.isBuffer(rawBody)) { + return rawBody; + } + if (typeof rawBody === 'string') { + return Buffer.from(rawBody, 'utf8'); + } + return undefined; +} + +function partsOf(header: string): { get: (key: string) => string | undefined; getAll: (key: string) => string[] } { + const pairs = header + .split(',') + .map((part) => part.trim()) + .map((part) => { + const index = part.indexOf('='); + return index === -1 ? ['', ''] : [part.slice(0, index), part.slice(index + 1)]; + }); + return { + get: (key) => pairs.find(([k]) => k === key)?.[1], + getAll: (key) => pairs.filter(([k]) => k === key).map(([, v]) => v), + }; +} + +function matches({ expected, signature }: { expected: Buffer; signature: string }): boolean { + if (!/^[0-9a-fA-F]+$/.test(signature)) { + return false; + } + const provided = Buffer.from(signature, 'hex'); + return provided.length === expected.length && crypto.timingSafeEqual(provided, expected); +} + +const TIMESTAMP_TOLERANCE_SECONDS = 900; +const MILLISECOND_TIMESTAMP_FLOOR = 1e12; + +type SignatureCheck = { + secret: string | undefined; + signatureHeader: string | undefined; + rawBody: unknown; + nowMs?: number; +}; diff --git a/packages/pieces/community/exa/src/lib/output-schemas-ai.ts b/packages/pieces/community/exa/src/lib/output-schemas-ai.ts new file mode 100644 index 000000000000..c786e79762b7 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/output-schemas-ai.ts @@ -0,0 +1,78 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; +import { exaSharedFields } from './output-schemas'; + +const twinPageFields: OutputSchemaField[] = [ + ...exaSharedFields.flatResultFields, + { key: 'links', label: 'Links on Page' }, + { + key: 'subpages', + label: 'Subpages', + labelKey: 'url', + listItems: exaSharedFields.flatResultFields, + }, +]; + +const costField: OutputSchemaField = { + key: 'cost_total', + label: 'Cost (USD)', + format: 'currency', + currency: 'USD', +}; + +export const exaSearchOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Results', + labelKey: 'title', + listItems: exaSharedFields.flatResultFields, + }, + { key: 'count', label: 'Result Count', format: 'number' }, + costField, + ], +}; + +export const exaGetContentsOutputSchema: OutputSchema = { + fields: [ + { + key: 'results', + label: 'Pages', + labelKey: 'url', + listItems: twinPageFields, + }, + { + key: 'statuses', + label: 'Per-URL Status', + labelKey: 'url', + listItems: [ + { key: 'url', label: 'URL', format: 'url' }, + { key: 'status', label: 'Status' }, + { key: 'source', label: 'Source' }, + { key: 'error_tag', label: 'Error' }, + { key: 'error_http_status', label: 'HTTP Status', format: 'number' }, + ], + }, + { key: 'failed_count', label: 'Failed URLs', format: 'number' }, + costField, + ], +}; + +export const exaAnswerOutputSchema: OutputSchema = { + fields: [ + { key: 'answer', label: 'Answer' }, + { + key: 'citations', + label: 'Citations', + labelKey: 'title', + listItems: [ + { key: 'title', label: 'Title' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'published_date', label: 'Published Date', format: 'date' }, + { key: 'author', label: 'Author' }, + { key: 'text', label: 'Page Text' }, + { key: 'id', label: 'Document ID' }, + ], + }, + costField, + ], +}; diff --git a/packages/pieces/community/exa/src/lib/output-schemas.ts b/packages/pieces/community/exa/src/lib/output-schemas.ts new file mode 100644 index 000000000000..10149c453213 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/output-schemas.ts @@ -0,0 +1,188 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const rawLinkFields: OutputSchemaField[] = [ + { key: 'title', label: 'Title' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'publishedDate', label: 'Published Date', format: 'date' }, + { key: 'author', label: 'Author' }, + { key: 'image', label: 'Image', format: 'image' }, + { key: 'id', label: 'Document ID' }, +]; + +const rawSearchFields: OutputSchemaField[] = [ + ...rawLinkFields, + { key: 'text', label: 'Page Text' }, + { key: 'highlights', label: 'Highlights' }, + { key: 'score', label: 'Relevance Score', format: 'number' }, +]; + +const rawSimilarFields: OutputSchemaField[] = [ + ...rawLinkFields, + { key: 'score', label: 'Relevance Score', format: 'number' }, +]; + +const rawSubpageFields: OutputSchemaField[] = [ + ...rawLinkFields, + { key: 'text', label: 'Page Text' }, +]; + +const rawContentsFields: OutputSchemaField[] = [ + ...rawLinkFields, + { key: 'text', label: 'Page Text' }, + { key: 'highlights', label: 'Highlights' }, + { key: 'summary', label: 'Summary' }, + { + key: 'extras', + label: 'Extras', + children: [{ key: 'links', label: 'Links on Page' }], + }, + { + key: 'subpages', + label: 'Subpages', + labelKey: 'title', + listItems: rawSubpageFields, + }, +]; + +const citationFields: OutputSchemaField[] = [ + { key: 'title', label: 'Title' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'field', label: 'Supports Field' }, + { key: 'confidence', label: 'Confidence' }, +]; + +const agentRunSummaryFields: OutputSchemaField[] = [ + { key: 'id', label: 'Run ID' }, + { key: 'status', label: 'Status' }, + { key: 'stop_reason', label: 'Stop Reason' }, + { key: 'query', label: 'Task' }, + { key: 'effort', label: 'Effort' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'completed_at', label: 'Completed At', format: 'datetime' }, + { key: 'cost_total', label: 'Cost (USD)', format: 'currency', currency: 'USD' }, +]; + +const flatResultFields: OutputSchemaField[] = [ + { key: 'title', label: 'Title' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'published_date', label: 'Published Date', format: 'date' }, + { key: 'author', label: 'Author' }, + { key: 'text', label: 'Page Text' }, + { key: 'highlights', label: 'Highlights' }, + { key: 'summary', label: 'Summary' }, + { key: 'image', label: 'Image', format: 'image' }, + { key: 'id', label: 'Document ID' }, +]; + +export const searchResultsOutputSchema: OutputSchema = { + itemLabel: '{title}', + fields: [ + { + key: 'results', + label: 'Results', + value: '', + labelKey: 'title', + listItems: rawSearchFields, + }, + ], +}; + +export const similarLinksOutputSchema: OutputSchema = { + itemLabel: '{title}', + fields: [ + { + key: 'results', + label: 'Similar Pages', + value: '', + labelKey: 'title', + listItems: rawSimilarFields, + }, + ], +}; + +export const contentsResultsOutputSchema: OutputSchema = { + itemLabel: '{title}', + fields: [ + { + key: 'results', + label: 'Pages', + value: '', + labelKey: 'title', + listItems: rawContentsFields, + }, + ], +}; + +export const agentRunOutputSchema: OutputSchema = { + fields: [ + ...agentRunSummaryFields, + { key: 'output_text', label: 'Answer' }, + { key: 'output_structured', label: 'Structured Output' }, + { + key: 'citations', + label: 'Citations', + labelKey: 'title', + listItems: citationFields, + }, + { key: 'search_count', label: 'Searches Used', format: 'number' }, + { key: 'agent_compute_units', label: 'Agent Compute Units', format: 'number' }, + ], +}; + +export const agentRunListOutputSchema: OutputSchema = { + fields: [ + { + key: 'runs', + label: 'Runs', + labelKey: 'id', + listItems: agentRunSummaryFields, + }, + { key: 'count', label: 'Runs in This Page', format: 'number' }, + { key: 'has_more', label: 'Has More', format: 'boolean' }, + { key: 'next_cursor', label: 'Next Cursor' }, + ], +}; + +export const searchMonitorRunOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Run Status' }, + { key: 'fail_reason', label: 'Fail Reason' }, + { + key: 'summary', + label: 'Summary', + description: "Exa's summary of the results in this payload. Empty when the run also held results that were delivered before; see Run Summary.", + }, + { key: 'result_count', label: 'New Results', format: 'number' }, + { + key: 'run_summary', + label: 'Run Summary', + description: "Exa's summary of the whole monitor run, which can mention results delivered before. Numbered markers such as [1] are Exa's own and do not reliably index Run Citations.", + }, + { key: 'run_result_count', label: 'Results in Run', format: 'number' }, + { + key: 'results', + label: 'Results', + labelKey: 'title', + listItems: flatResultFields, + }, + { + key: 'citations', + label: 'Citations', + labelKey: 'title', + description: 'Sources for the results in this payload.', + listItems: citationFields, + }, + { + key: 'run_citations', + label: 'Run Citations', + labelKey: 'title', + description: 'Every source Exa cited for the whole run, in Exa\'s order.', + listItems: citationFields, + }, + { key: 'run_id', label: 'Run ID' }, + { key: 'monitor_id', label: 'Monitor ID' }, + { key: 'event_id', label: 'Event ID' }, + { key: 'event_created_at', label: 'Delivered At', format: 'datetime' }, + ], +}; +export const exaSharedFields = { flatResultFields }; diff --git a/packages/pieces/community/exa/src/lib/triggers/new-search-monitor-results.ts b/packages/pieces/community/exa/src/lib/triggers/new-search-monitor-results.ts new file mode 100644 index 000000000000..4242c42f3111 --- /dev/null +++ b/packages/pieces/community/exa/src/lib/triggers/new-search-monitor-results.ts @@ -0,0 +1,558 @@ +import { + createTrigger, + Property, + Store, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { exaAuth } from '../auth'; +import { exaApi, exaInput } from '../common/client'; +import { exaWebhookSignature } from '../common/webhook-signature'; +import { searchMonitorRunOutputSchema } from '../output-schemas'; + +export const newSearchMonitorResultsTrigger = createTrigger({ + auth: exaAuth, + name: 'new_search_monitor_results', + classification: 'READ', + displayName: 'New Search Monitor Results', + description: + 'Runs a search on a schedule and triggers with the new results Exa found since the last run.', + aiMetadata: { + description: + 'Fires once per completed Exa search-monitor run: Exa repeats the configured query on the chosen interval, removes results it already returned, and delivers the new results with their citations; summary describes exactly those results and is null when the run also held results delivered before (run_summary, run_result_count and run_citations then describe the whole run). Numbered markers such as [1] in the summaries come from Exa and do not reliably index the citation lists. Check status and fail_reason before using the results. Each run is billed by Exa (about $0.015 per run).', + }, + props: { + info: Property.MarkDown({ + value: `Exa creates a **Monitor** that repeats this search on a schedule and sends only new results. + +- Each run costs about **$0.015** on your Exa account (plus $0.001 per result above 10). An hourly monitor is about 720 runs a month (≈ $11); daily is about 30 (≈ $0.45). +- Exa only delivers to a public **HTTPS** address. On a self-hosted instance, the Activepieces webhook URL must be reachable from the internet. +- Testing the trigger in the builder runs one search (about $0.007, plus content charges when page text or highlights are on) and shows its results; it does not create a monitor. +- Results that were already delivered are not sent again, also after the flow is republished. A run whose results were all delivered before is skipped. When a run mixes old and new results, only the new ones are sent, with their citations; Summary is then empty, and Run Summary and Run Citations cover the whole run. Numbered markers such as [1] in Exa's summaries are Exa's own; Exa does not document what they point to, so use the citation lists for sources. +- Turning the flow off deletes the monitor, retrying if Exa is briefly unavailable. If it still can't be deleted, remove it in the Exa dashboard: it is named after this flow unless you set Monitor Name.`, + }), + query: Property.LongText({ + displayName: 'Search Query', + description: + 'What to watch for, written as the ongoing signal rather than a date range. Example: "new battery recycling facilities announced in North America".', + required: true, + }), + period: Property.StaticDropdown({ + displayName: 'Check Every', + description: 'How often Exa runs the search. Each run may start up to 30 minutes late.', + required: true, + defaultValue: '1d', + options: { + options: [ + { label: 'Hour', value: '1h' }, + { label: '6 hours', value: '6h' }, + { label: 'Day', value: '1d' }, + { label: 'Week', value: '7d' }, + ], + }, + }), + numResults: Property.Number({ + displayName: 'Max Results per Run', + description: 'Up to how many new results each run returns, 1 to 100. Defaults to 10; results above 10 cost extra.', + required: false, + defaultValue: 10, + }), + includeDomains: Property.Array({ + displayName: 'Include Domains', + description: 'Only return results from these domains, e.g. "techcrunch.com". Leave empty for the whole web.', + required: false, + }), + excludeDomains: Property.Array({ + displayName: 'Exclude Domains', + description: 'Never return results from these domains.', + required: false, + }), + includeText: Property.Checkbox({ + displayName: 'Include Page Text', + description: 'Add the full page text to each result.', + required: false, + defaultValue: false, + }), + includeHighlights: Property.Checkbox({ + displayName: 'Include Highlights', + description: 'Add the most relevant snippets from each page to each result.', + required: false, + defaultValue: false, + }), + monitorName: Property.ShortText({ + displayName: 'Monitor Name', + description: 'Optional name shown for this monitor in the Exa dashboard.', + required: false, + }), + }, + sampleData: sampleRun(), + outputSchema: searchMonitorRunOutputSchema, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + if (!context.webhookUrl.startsWith('https://')) { + throw new Error( + `Exa only delivers to a public HTTPS address, and this flow's webhook URL is ${context.webhookUrl}. Make the Activepieces webhook URL reachable over HTTPS from the internet.`, + ); + } + const apiKey = context.auth.secret_text; + const search = monitorSearchOf(context.propsValue); + const name = exaInput.optionalText(context.propsValue.monitorName) ?? `Activepieces flow ${context.flows.current.id}`; + + const previous = await context.store.get(STORE_KEY); + + const monitor = await exaApi.call<{ id: string; webhookSecret?: string }>({ + apiKey, + method: HttpMethod.POST, + path: '/monitors', + body: { + name, + search, + trigger: { type: 'interval', period: context.propsValue.period }, + webhook: { + url: context.webhookUrl, + events: ['monitor.run.completed'], + }, + }, + }); + + const staleBefore = staleIdsOf(previous); + const webhookSecret = monitor.webhookSecret; + if (!webhookSecret) { + const cleanup = await rollBackMonitor({ apiKey, monitorId: monitor.id }); + if (!cleanup.deleted) { + try { + await context.store.put(STORE_KEY, { + monitorId: previous?.monitorId ?? null, + webhookSecret: previous?.webhookSecret ?? null, + stale: uniqueIds([...staleBefore, monitor.id]), + }); + } catch (error) { + throw new Error( + `Exa created the monitor without a webhook secret, and it could not be saved for cleanup: ${messageOf(error)}. ${cleanup.message}`, + ); + } + } + throw new Error(`Exa created the monitor without a webhook secret, so deliveries could not be verified. ${cleanup.message} Turn the flow on again.`); + } + const stale = uniqueIds([...staleBefore, ...(previous?.monitorId ? [previous.monitorId] : [])]).filter((id) => id !== monitor.id); + try { + await context.store.put(STORE_KEY, { monitorId: monitor.id, webhookSecret, stale }); + } catch (error) { + const cleanup = await rollBackMonitor({ apiKey, monitorId: monitor.id }); + throw new Error(`Could not save the Exa monitor ID: ${messageOf(error)}. ${cleanup.message}`); + } + await drainStaleMonitors({ store: context.store, apiKey, state: { monitorId: monitor.id, webhookSecret, stale } }); + }, + async onDisable(context) { + const apiKey = context.auth.secret_text; + const state = await context.store.get(STORE_KEY); + if (!state) { + return; + } + if (state.monitorId) { + try { + await deleteMonitor({ apiKey, monitorId: state.monitorId }); + } catch (error) { + throw new Error( + `Could not delete the Exa monitor ${state.monitorId}: ${messageOf(error)}. It stays recorded and is retried the next time the flow is turned on or off; you can also delete it in the Exa dashboard.`, + ); + } + } + await drainStaleMonitors({ store: context.store, apiKey, state: { monitorId: null, webhookSecret: null, stale: staleIdsOf(state) } }); + }, + async test(context) { + const search = monitorSearchOf(context.propsValue); + const response = await exaApi.call<{ results?: unknown[] }>({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + path: '/search', + body: search, + }); + const results = (response.results ?? []).map(resultOf); + return [ + { + event_id: null, + event_created_at: null, + run_id: null, + monitor_id: null, + status: 'completed', + fail_reason: null, + summary: null, + run_summary: null, + result_count: results.length, + run_result_count: results.length, + results, + citations: [], + run_citations: [], + }, + ]; + }, + async run(context) { + const state = await context.store.get(STORE_KEY); + const signatureHeader = exaWebhookSignature.headerOf({ + headers: context.payload.headers, + name: exaWebhookSignature.HEADER, + }); + const problem = exaWebhookSignature.problemOf({ + secret: state?.webhookSecret ?? undefined, + signatureHeader, + rawBody: context.payload.rawBody, + }); + if (problem !== undefined || !state?.monitorId) { + console.warn(`[exa] Dropped a search-monitor delivery because ${problem ?? 'the flow has no monitor'}.`); + return []; + } + const event = eventOf(context.payload.body); + if (!event || event.type !== 'monitor.run.completed' || event.data?.monitorId !== state.monitorId) { + return []; + } + const seen = (await context.store.get(SEEN_KEY)) ?? { eventIds: [], urls: [] }; + const dedupeId = event.id ?? event.data?.id; + if (dedupeId && seen.eventIds.includes(dedupeId)) { + return []; + } + const run = flattenEvent(event); + const fresh = run.results.filter((result) => result.url === null || !seen.urls.includes(result.url)); + const freshUrls = fresh.flatMap((result) => (result.url === null ? [] : [result.url])); + const freshUrlSet = new Set(freshUrls); + const complete = fresh.length === run.results.length; + await context.store.put(SEEN_KEY, { + eventIds: (dedupeId ? [dedupeId, ...seen.eventIds] : seen.eventIds).slice(0, SEEN_EVENT_LIMIT), + urls: [...freshUrls, ...seen.urls].slice(0, SEEN_URL_LIMIT), + }); + if (run.results.length > 0 && fresh.length === 0) { + return []; + } + return [ + { + ...run, + results: fresh, + result_count: fresh.length, + summary: complete ? run.run_summary : null, + citations: complete + ? run.citations + : run.citations.filter((citation) => citation.url !== null && freshUrlSet.has(citation.url)), + }, + ]; + }, +}); + +function monitorSearchOf(propsValue: { + query: string; + numResults?: number; + includeDomains?: unknown[]; + excludeDomains?: unknown[]; + includeText?: boolean; + includeHighlights?: boolean; +}): Record { + const numResults = exaInput.optionalInteger({ + value: propsValue.numResults, + name: 'Max Results per Run', + min: 1, + max: 100, + }); + const includeDomains = exaInput.stringList(propsValue.includeDomains); + const excludeDomains = exaInput.stringList(propsValue.excludeDomains); + const contents = { + ...(propsValue.includeText ? { text: true } : {}), + ...(propsValue.includeHighlights ? { highlights: true } : {}), + }; + return { + query: propsValue.query, + ...(numResults !== undefined ? { numResults } : {}), + ...(includeDomains ? { includeDomains } : {}), + ...(excludeDomains ? { excludeDomains } : {}), + ...(Object.keys(contents).length > 0 ? { contents } : {}), + }; +} + +async function deleteMonitor({ apiKey, monitorId }: { apiKey: string; monitorId: string }): Promise { + for (let attempt = 0; ; attempt++) { + try { + await exaApi.call({ + apiKey, + method: HttpMethod.DELETE, + path: `/monitors/${encodeURIComponent(monitorId)}`, + }); + return; + } catch (error) { + const status = exaApi.statusOf(error); + if (status === 404) { + return; + } + const retryable = status === undefined || status === 429 || status >= 500; + if (!retryable || attempt >= DELETE_RETRY_DELAYS_MS.length) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, DELETE_RETRY_DELAYS_MS[attempt])); + } + } +} + +async function drainStaleMonitors({ store, apiKey, state }: { store: Store; apiKey: string; state: MonitorState }): Promise { + const remaining: string[] = []; + for (const monitorId of state.stale) { + try { + await deleteMonitor({ apiKey, monitorId }); + } catch { + remaining.push(monitorId); + } + } + try { + if (state.monitorId === null && remaining.length === 0) { + await store.delete(STORE_KEY); + } else { + await store.put(STORE_KEY, { ...state, stale: remaining }); + } + } catch (error) { + console.warn( + `[exa] Could not update the monitor cleanup list (${messageOf(error)}). Monitors still to delete: ${remaining.length > 0 ? remaining.join(', ') : 'none'}; the stored list is unchanged and is retried on the next enable or disable.`, + ); + } +} + +function staleIdsOf(state: MonitorState | null): string[] { + return Array.isArray(state?.stale) ? state.stale.filter((id) => typeof id === 'string') : []; +} + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids)]; +} + +async function rollBackMonitor({ apiKey, monitorId }: { apiKey: string; monitorId: string }): Promise<{ deleted: boolean; message: string }> { + try { + await deleteMonitor({ apiKey, monitorId }); + return { deleted: true, message: 'The new monitor was deleted again.' }; + } catch (error) { + return { + deleted: false, + message: `The new monitor ${monitorId} could not be deleted (${messageOf(error)}); delete it in the Exa dashboard.`, + }; + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function eventOf(body: unknown): MonitorEvent | undefined { + if (typeof body !== 'object' || body === null) { + return undefined; + } + const type: unknown = Reflect.get(body, 'type'); + if (typeof type !== 'string') { + return undefined; + } + const id: unknown = Reflect.get(body, 'id'); + const createdAt: unknown = Reflect.get(body, 'createdAt'); + const data: unknown = Reflect.get(body, 'data'); + return { + id: typeof id === 'string' ? id : undefined, + type, + createdAt: typeof createdAt === 'string' ? createdAt : undefined, + data: typeof data === 'object' && data !== null ? runOf(data) : undefined, + }; +} + +function runOf(data: object): MonitorRun { + const output: unknown = Reflect.get(data, 'output'); + const outputObject = typeof output === 'object' && output !== null ? output : undefined; + const results: unknown = outputObject ? Reflect.get(outputObject, 'results') : undefined; + const grounding: unknown = outputObject ? Reflect.get(outputObject, 'grounding') : undefined; + return { + id: stringOf(Reflect.get(data, 'id')), + monitorId: stringOf(Reflect.get(data, 'monitorId')), + status: stringOf(Reflect.get(data, 'status')), + failReason: stringOf(Reflect.get(data, 'failReason')), + content: outputObject ? Reflect.get(outputObject, 'content') ?? null : null, + results: Array.isArray(results) ? results : [], + grounding: Array.isArray(grounding) ? grounding : [], + }; +} + +function flattenEvent(event: MonitorEvent): FlatMonitorRun { + const run = event.data; + const results = (run?.results ?? []).map(resultOf); + const content = run?.content ?? null; + const citations = citationsOf(run?.grounding ?? []); + return { + event_id: event.id ?? null, + event_created_at: event.createdAt ?? null, + run_id: run?.id ?? null, + monitor_id: run?.monitorId ?? null, + status: run?.status ?? null, + fail_reason: run?.failReason ?? null, + summary: typeof content === 'string' ? content : null, + run_summary: typeof content === 'string' ? content : null, + result_count: results.length, + run_result_count: results.length, + results, + citations, + run_citations: citations, + }; +} + +function resultOf(item: unknown): FlatMonitorResult { + const get = (key: string): unknown => + typeof item === 'object' && item !== null ? Reflect.get(item, key) : undefined; + const highlights = get('highlights'); + return { + id: stringOf(get('id')), + title: stringOf(get('title')), + url: stringOf(get('url')), + published_date: isoDateOf(get('publishedDate')), + author: stringOf(get('author')), + text: stringOf(get('text')), + highlights: Array.isArray(highlights) ? highlights.map(String).join('\n') : null, + summary: stringOf(get('summary')), + image: stringOf(get('image')), + }; +} + +function citationsOf(grounding: unknown[]): MonitorCitation[] { + return grounding.flatMap((entry) => { + if (typeof entry !== 'object' || entry === null) { + return []; + } + const citations: unknown = Reflect.get(entry, 'citations'); + if (!Array.isArray(citations)) { + return []; + } + return citations.map((citation: unknown) => ({ + field: stringOf(Reflect.get(entry, 'field')), + url: typeof citation === 'object' && citation !== null ? stringOf(Reflect.get(citation, 'url')) : null, + title: typeof citation === 'object' && citation !== null ? stringOf(Reflect.get(citation, 'title')) : null, + confidence: stringOf(Reflect.get(entry, 'confidence')), + })); + }); +} + +function isoDateOf(value: unknown): string | null { + const text = stringOf(value); + if (text === null) { + return null; + } + const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?Z?$/.exec(text); + if (!match) { + return text; + } + const millis = (match[3] ?? '.000').slice(1, 4).padEnd(3, '0'); + return `${match[1]}T${match[2]}.${millis}Z`; +} + +function stringOf(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function sampleRun(): Record { + return { + event_id: 'event_01k4d9x2m5n8p1q4r7s0t3v6w9', + event_created_at: '2026-09-05T20:00:00.000Z', + run_id: '01k4d9w8a1b2c3d4e5f6g7h8j9', + monitor_id: '01k4d9w6y3h7p2m8n5q1r0s4tv', + status: 'completed', + fail_reason: null, + summary: 'Two new battery recycling facilities were announced in Ontario and Nevada this week. [1]', + run_summary: 'Two new battery recycling facilities were announced in Ontario and Nevada this week. [1]', + result_count: 1, + run_result_count: 1, + results: [ + { + id: 'https://example.com/announcement', + title: 'New battery recycling facility announced', + url: 'https://example.com/announcement', + published_date: '2026-09-04T00:00:00.000Z', + author: null, + text: null, + highlights: null, + summary: null, + image: 'https://example.com/announcement/cover.webp', + }, + ], + citations: [ + { + field: 'content', + url: 'https://example.com/announcement', + title: 'New battery recycling facility announced', + confidence: 'high', + }, + ], + run_citations: [ + { + field: 'content', + url: 'https://example.com/announcement', + title: 'New battery recycling facility announced', + confidence: 'high', + }, + ], + }; +} + +const STORE_KEY = 'exa_search_monitor'; +const SEEN_EVENT_LIMIT = 50; +const SEEN_KEY = 'exa_search_monitor_seen'; +const SEEN_URL_LIMIT = 500; +const DELETE_RETRY_DELAYS_MS = [500, 1500]; + +type SeenState = { + eventIds: string[]; + urls: string[]; +}; + +type MonitorState = { + monitorId: string | null; + webhookSecret: string | null; + stale: string[]; +}; + +type MonitorRun = { + id: string | null; + monitorId: string | null; + status: string | null; + failReason: string | null; + content: unknown; + results: unknown[]; + grounding: unknown[]; +}; + +type MonitorEvent = { + id: string | undefined; + type: string; + createdAt: string | undefined; + data: MonitorRun | undefined; +}; + +type FlatMonitorResult = { + id: string | null; + title: string | null; + url: string | null; + published_date: string | null; + author: string | null; + text: string | null; + highlights: string | null; + summary: string | null; + image: string | null; +}; + +type FlatMonitorRun = { + event_id: string | null; + event_created_at: string | null; + run_id: string | null; + monitor_id: string | null; + status: string | null; + fail_reason: string | null; + summary: string | null; + run_summary: string | null; + result_count: number; + run_result_count: number; + results: FlatMonitorResult[]; + citations: MonitorCitation[]; + run_citations: MonitorCitation[]; +}; + +type MonitorCitation = { + field: string | null; + url: string | null; + title: string | null; + confidence: string | null; +}; diff --git a/packages/pieces/community/exa/test/ai-twins.test.ts b/packages/pieces/community/exa/test/ai-twins.test.ts new file mode 100644 index 000000000000..515c2eb5955c --- /dev/null +++ b/packages/pieces/community/exa/test/ai-twins.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { exaSearchAction } from '../src/lib/actions/ai/exa-search'; +import { exaGetContentsAction } from '../src/lib/actions/ai/exa-get-contents'; +import { exaAnswerAction } from '../src/lib/actions/ai/exa-answer'; +import { lastRequest, ok, runAction, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('exa_search', () => { + it('sends include and exclude domains together, as the Exa spec allows', async () => { + ok({ body: { results: [] } }); + await runAction({ + action: exaSearchAction, + propsValue: { query: 'q', includeText: false, includeDomains: ['example.com'], excludeDomains: ['example.com/careers'] }, + }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body).toEqual({ + query: 'q', + type: 'auto', + includeDomains: ['example.com'], + excludeDomains: ['example.com/careers'], + }); + }); + + it('refuses date filters with the company category', async () => { + await expect( + runAction({ action: exaSearchAction, propsValue: { query: 'q', category: 'company', startPublishedDate: '2026-01-01T00:00:00Z' } }), + ).rejects.toThrow(/company category/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('refuses an unknown search type and a malformed country before calling Exa', async () => { + await expect(runAction({ action: exaSearchAction, propsValue: { query: 'q', type: 'semantic' } })).rejects.toThrow( + /Search Type must be one of/, + ); + await expect(runAction({ action: exaSearchAction, propsValue: { query: 'q', userLocation: 'USA' } })).rejects.toThrow( + /two-letter country code/, + ); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('flattens results to the output schema keys and reports cost', async () => { + ok({ + body: { + results: [{ id: 'u', title: 'T', url: 'https://t.com', highlights: ['a', 'b'], favicon: 'https://t.com/f.ico' }], + costDollars: { total: 0.007 }, + }, + }); + const result = await runAction({ + action: exaSearchAction, + propsValue: { query: 'q', includeText: true, highlightsQuery: 'why', userLocation: 'de' }, + }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body).toEqual({ + query: 'q', + type: 'auto', + userLocation: 'DE', + contents: { text: true, highlights: { query: 'why' } }, + }); + expect(result).toEqual({ + count: 1, + cost_total: 0.007, + results: [ + { + id: 'u', + title: 'T', + url: 'https://t.com', + published_date: null, + author: null, + text: null, + highlights: 'a\nb', + summary: null, + image: null, + }, + ], + }); + }); +}); + +describe('exa_get_contents', () => { + it('surfaces failed URLs', async () => { + ok({ + body: { + results: [{ id: 'https://a.com', url: 'https://a.com', text: 'x' }], + statuses: [ + { id: 'https://a.com', status: 'success', source: 'cached' }, + { id: 'https://bad.example', status: 'error', error: { tag: 'CRAWL_NOT_FOUND', httpStatusCode: 404 } }, + ], + }, + }); + const result = await runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com', 'https://bad.example'] } }); + expect(result).toMatchObject({ + failed_count: 1, + statuses: [ + { url: 'https://a.com', status: 'success', source: 'cached', error_tag: null }, + { url: 'https://bad.example', status: 'error', error_tag: 'CRAWL_NOT_FOUND', error_http_status: 404 }, + ], + }); + }); + + it('returns each page\'s subpages instead of dropping them', async () => { + ok({ + body: { + results: [{ id: 'https://a.com', url: 'https://a.com', subpages: [{ id: 'https://a.com/pricing', url: 'https://a.com/pricing', title: 'Pricing', text: 'p', favicon: 'f' }] }], + statuses: [{ id: 'https://a.com', status: 'success' }], + }, + }); + const result = await runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com'], subpages: 1, subpageTarget: 'pricing' } }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body).toMatchObject({ subpages: 1, subpageTarget: 'pricing' }); + expect(result).toMatchObject({ + results: [{ url: 'https://a.com', subpages: [{ url: 'https://a.com/pricing', title: 'Pricing', text: 'p' }] }], + failed_count: 0, + }); + expect(JSON.stringify(result)).not.toContain('favicon'); + }); + + it('counts each failed URL once, and a subpage status cannot hide a failed page', async () => { + ok({ + body: { + results: [{ id: 'https://a.com', url: 'https://a.com' }], + statuses: [ + { id: 'https://a.com', status: 'success' }, + { id: 'https://b.com/sub', status: 'success' }, + { id: 'https://b.com', status: 'error', error: { tag: 'CRAWL_NOT_FOUND', httpStatusCode: 404 } }, + ], + }, + }); + const result = await runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com', 'https://b.com', 'https://b.com'] } }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body.urls).toEqual(['https://a.com', 'https://b.com']); + expect(result).toMatchObject({ failed_count: 1 }); + }); + + it('counts a URL missing from the statuses as failed', async () => { + ok({ body: { results: [{ id: 'https://a.com', url: 'https://a.com', text: 'x' }] } }); + const result = await runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com', 'https://b.com'] } }); + expect(result).toMatchObject({ failed_count: 1, statuses: [] }); + }); + + it('refuses a request that asks for no content before calling Exa', async () => { + await expect(runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com'], includeText: false } })).rejects.toThrow( + /Choose at least one thing to return/, + ); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('refuses more than 100 URLs before calling Exa', async () => { + const urls = Array.from({ length: 101 }, (_, index) => `https://a.com/${index}`); + await expect(runAction({ action: exaGetContentsAction, propsValue: { urls } })).rejects.toThrow(/at most 100 URLs/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('returns outgoing links per page', async () => { + ok({ body: { results: [{ id: 'https://a.com', url: 'https://a.com', extras: { links: ['https://a.com/x', 'https://a.com/y'] } }], statuses: [] } }); + const result = await runAction({ action: exaGetContentsAction, propsValue: { urls: ['https://a.com'], linksPerPage: 2 } }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body.extras).toEqual({ links: 2 }); + expect(result).toMatchObject({ results: [{ url: 'https://a.com', links: ['https://a.com/x', 'https://a.com/y'] }] }); + }); +}); + +describe('exa_answer', () => { + it('returns citations', async () => { + ok({ body: { answer: 'yes', citations: [{ id: 'c', title: 'C', url: 'https://c.com' }], costDollars: { total: 0.005 } } }); + const result = await runAction({ action: exaAnswerAction, propsValue: { query: 'q' } }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().body).toEqual({ query: 'q', model: 'exa', text: false }); + expect(result).toMatchObject({ answer: 'yes', citations: [{ id: 'c', title: 'C', url: 'https://c.com' }], cost_total: 0.005 }); + }); + + it('returns a null answer, not the text "null", when Exa sends none', async () => { + ok({ body: { citations: [] } }); + const result = await runAction({ action: exaAnswerAction, propsValue: { query: 'q' } }); + expect(result).toMatchObject({ answer: null, citations: [] }); + }); + + it('refuses an unknown model before calling Exa', async () => { + await expect(runAction({ action: exaAnswerAction, propsValue: { query: 'q', model: 'gpt-4' } })).rejects.toThrow( + /Model must be one of/, + ); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/pieces/community/exa/test/exa.test.ts b/packages/pieces/community/exa/test/exa.test.ts new file mode 100644 index 000000000000..dd093698fe10 --- /dev/null +++ b/packages/pieces/community/exa/test/exa.test.ts @@ -0,0 +1,964 @@ +import crypto from 'crypto'; +import { Store } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { performSearchAction } from '../src/lib/actions/perform-search'; +import { getContentsAction } from '../src/lib/actions/get-contents'; +import { generateAnswerAction } from '../src/lib/actions/generate-answer'; +import { findSimilarLinksAction } from '../src/lib/actions/find-similar-links'; +import { createAgentRunAction } from '../src/lib/actions/agent/create-agent-run'; +import { getAgentRunAction } from '../src/lib/actions/agent/get-agent-run'; +import { listAgentRunsAction } from '../src/lib/actions/agent/list-agent-runs'; +import { cancelAgentRunAction } from '../src/lib/actions/agent/cancel-agent-run'; +import { newSearchMonitorResultsTrigger as monitorTrigger } from '../src/lib/triggers/new-search-monitor-results'; +import { exaWebhookSignature } from '../src/lib/common/webhook-signature'; +import { exaInput } from '../src/lib/common/client'; +import { exa } from '../src/index'; +import { exaAuth } from '../src/lib/auth'; +import { asWebhook, fail, lastRequest, memoryStore, ok, runAction, sendRequest, webhookContext } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const RUN = { + id: 'agent_run_01', + object: 'agent_run', + status: 'completed', + stopReason: 'schema_satisfied', + createdAt: '2026-09-29T10:00:00.000Z', + completedAt: '2026-09-29T10:01:00.000Z', + request: { query: 'q', effort: 'low' }, + output: { + text: 'answer', + structured: { companies: [] }, + grounding: [ + { field: 'companies', citations: [{ url: 'https://a.com', title: 'A' }, { url: 'https://b.com' }], confidence: 'high' }, + ], + }, + usage: { agentComputeUnits: 1, searches: 3, emails: 0, phoneNumbers: 0 }, + costDollars: { total: 0.025, agentCompute: 0, search: 0, emails: 0, phoneNumbers: 0 }, +}; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +function validateKey(auth: string) { + return exaAuth.validate?.({ + auth, + server: { + apiUrl: 'http://localhost:3000', + publicUrl: 'http://localhost:4200', + mintOidcToken: async () => 'oidc-token', + }, + }); +} + +describe('existing actions keep their default request', () => { + it('perform_search sends the same body as 0.2.1 when no new prop is set', async () => { + ok({ body: { results: [{ title: 't' }] } }); + const result = await runAction({ action: performSearchAction, propsValue: { query: 'ai', type: 'auto', numResults: 10 } }); + expect(lastRequest().body).toEqual({ query: 'ai', contents: { text: true }, type: 'auto', numResults: 10 }); + expect(lastRequest().url).toBe('https://api.exa.ai/search'); + expect(result).toEqual([{ title: 't' }]); + }); + + it('perform_search nests text limit, highlights and maxAgeHours under contents', async () => { + ok({ body: { results: [] } }); + await runAction({ + action: performSearchAction, + propsValue: { + query: 'ai', + textMaxCharacters: 500, + highlights: true, + maxAgeHours: 0, + userLocation: ' us ', + moderation: true, + }, + }); + expect(lastRequest().body).toEqual({ + query: 'ai', + contents: { text: { maxCharacters: 500 }, highlights: true, maxAgeHours: 0 }, + userLocation: 'US', + moderation: true, + }); + }); + + it('perform_search refuses a country that is not a two-letter code before calling Exa', async () => { + await expect( + runAction({ action: performSearchAction, propsValue: { query: 'ai', userLocation: 'United States' } }), + ).rejects.toThrow(/two-letter country code/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('get_contents drops the deprecated livecrawl when maxAgeHours is set', async () => { + ok({ body: { results: [] } }); + await runAction({ + action: getContentsAction, + propsValue: { urls: ['https://a.com'], text: true, livecrawl: 'always', maxAgeHours: 24 }, + }); + expect(lastRequest().body).toEqual({ urls: ['https://a.com'], text: true, maxAgeHours: 24 }); + }); + + it('describes only the result fields each action can return', () => { + const keysOf = (schema: typeof getContentsAction.outputSchema) => schema?.fields[0]?.listItems?.map((field) => field.key); + expect(keysOf(getContentsAction.outputSchema)).not.toContain('score'); + expect(keysOf(getContentsAction.outputSchema)).toEqual(expect.arrayContaining(['extras', 'subpages', 'summary'])); + expect(keysOf(performSearchAction.outputSchema)).not.toContain('summary'); + expect(keysOf(findSimilarLinksAction.outputSchema)).toEqual(expect.not.arrayContaining(['text', 'highlights', 'summary'])); + }); + + it('get_contents keeps livecrawl when maxAgeHours is not set', async () => { + ok({ body: { results: [] } }); + await runAction({ + action: getContentsAction, + propsValue: { urls: ['https://a.com'], text: true, livecrawl: 'fallback', summaryQuery: 'who?', extrasLinks: 5 }, + }); + expect(lastRequest().body).toEqual({ + urls: ['https://a.com'], + text: true, + livecrawl: 'fallback', + summary: { query: 'who?' }, + extras: { links: 5 }, + }); + }); + + it('generate_answer still returns the answer string and adds optional instructions', async () => { + ok({ body: { answer: '42', citations: [] } }); + const result = await runAction({ + action: generateAnswerAction, + propsValue: { query: 'q', text: false, model: 'exa-fast', systemPrompt: 'short' }, + }); + expect(result).toBe('42'); + expect(lastRequest().body).toEqual({ query: 'q', text: false, model: 'exa-fast', systemPrompt: 'short' }); + }); +}); + +describe('custom API call', () => { + const customApiCall = exa.actions()['custom_api_call']; + + function callWithUrl(url: string) { + return runAction({ + action: customApiCall, + propsValue: { url: { url }, method: 'GET', headers: {}, queryParams: {}, failsafe: false }, + }); + } + + it('sends the secret text, not [object Object]', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + await callWithUrl('/agent/runs'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('https://api.exa.ai/agent/runs'); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('x-api-key')).toBe('exa_test_key'); + fetchSpy.mockRestore(); + }); + + it('accepts a full URL on api.exa.ai', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + await callWithUrl('https://api.exa.ai/agent/runs'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('x-api-key')).toBe('exa_test_key'); + fetchSpy.mockRestore(); + }); + + it.each([ + 'https://api.exa.ai.evil.io/search', + 'https://evil.io/search', + 'http://api.exa.ai/search', + 'https://user@api.exa.ai/search', + 'https://api.exa.ai:8443/search', + ])('never sends the API key to %s', async (url) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + await expect(callWithUrl(url)).rejects.toThrow(/only sends your Exa API key/); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe('custom API call redirects', () => { + it('does not follow redirects when the option was never saved on the step', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + await runAction({ + action: exa.actions()['custom_api_call'], + propsValue: { url: { url: '/agent/runs' }, method: 'GET', headers: {}, queryParams: {}, failsafe: false }, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][1]?.redirect).toBe('manual'); + fetchSpy.mockRestore(); + }); + + it('refuses Follow redirects, which could carry the key to another host', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + await expect( + runAction({ + action: exa.actions()['custom_api_call'], + propsValue: { url: { url: '/agent/runs' }, method: 'GET', headers: {}, queryParams: {}, failsafe: false, followRedirects: true }, + }), + ).rejects.toThrow(/Follow redirects is not supported/); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe('legacy actions', () => { + it('find_similar_links sends the reference URL and filters as before', async () => { + ok({ body: { results: [{ url: 'https://b.com', score: 0.9 }] } }); + const result = await runAction({ + action: findSimilarLinksAction, + propsValue: { url: 'https://a.com', numResults: 2, includeDomains: ['b.com'], startPublishedDate: '2026-01-01T00:00:00Z' }, + }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest()).toMatchObject({ method: 'POST', url: 'https://api.exa.ai/findSimilar' }); + expect(lastRequest().body).toEqual({ url: 'https://a.com', numResults: 2, includeDomains: ['b.com'], startPublishedDate: '2026-01-01T00:00:00Z' }); + expect(result).toEqual([{ url: 'https://b.com', score: 0.9 }]); + }); + + it.each([ + [402, /insufficient credits/], + [429, /rate limit/], + ])('maps a %s from Exa to a clear message', async (status, message) => { + fail({ status, body: { error: 'vendor detail' } }); + await expect(runAction({ action: performSearchAction, propsValue: { query: 'q' } })).rejects.toThrow(message); + }); +}); + +describe('redirects from Exa', () => { + it.each([ + ['an agent-run action', () => runAction({ action: getAgentRunAction, propsValue: { runId: 'agent_run_01' } })], + ['a legacy action', () => runAction({ action: performSearchAction, propsValue: { query: 'q' } })], + ])('%s asks not to follow redirects and fails on a 3xx', async (_name, call) => { + sendRequest.mockResolvedValueOnce({ status: 302, headers: { location: 'https://elsewhere.example/' }, body: '' }); + await expect(call()).rejects.toThrow(/redirect \(302\)/); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().followRedirects).toBe(false); + }); +}); + +describe('auth.validate', () => { + it('uses the non-billable list endpoint', async () => { + ok({ body: { data: [] } }); + await expect(validateKey('k')).resolves.toEqual({ valid: true }); + expect(lastRequest().method).toBe('GET'); + expect(lastRequest().url).toBe('https://api.exa.ai/agent/runs'); + expect(lastRequest().queryParams).toEqual({ limit: '1' }); + }); + + it('says invalid key only on 401', async () => { + fail({ status: 401 }); + await expect(validateKey('k')).resolves.toEqual({ valid: false, error: 'Invalid API Key.' }); + }); + + it('says invalid key on 403', async () => { + fail({ status: 403 }); + await expect(validateKey('k')).resolves.toEqual({ valid: false, error: 'Invalid API Key.' }); + }); + + it('does not blame the key on a server error', async () => { + fail({ status: 503 }); + const result = await validateKey('k'); + expect(result).toMatchObject({ valid: false }); + expect(JSON.stringify(result)).toContain('HTTP 503'); + }); +}); + +describe('input parsing', () => { + it('accepts numbers sent as text', () => { + expect(exaInput.optionalInteger({ value: '500', name: 'Limit', min: 1, max: 1000 })).toBe(500); + expect(() => exaInput.optionalInteger({ value: '1.5', name: 'Limit', min: 1, max: 1000 })).toThrow(/whole number/); + }); + + it('drops blank list entries and treats an empty list as not set', () => { + expect(exaInput.stringList(['a.com', ' ', 'b.com'])).toEqual(['a.com', 'b.com']); + expect(exaInput.stringList([' '])).toBeUndefined(); + }); +}); + +describe('agent runs', () => { + it('defaults effort to low when the caller omits it', async () => { + ok({ body: { ...RUN, status: 'queued', output: { text: '', structured: null, grounding: [] } } }); + const result = await runAction({ action: createAgentRunAction, propsValue: { query: 'find things' } }); + expect(lastRequest().body).toEqual({ query: 'find things', effort: 'low' }); + expect(result).toMatchObject({ id: 'agent_run_01', status: 'queued' }); + }); + + it('refuses a budget on a fixed effort before calling Exa', async () => { + await expect( + runAction({ action: createAgentRunAction, propsValue: { query: 'q', effort: 'low', maxCostDollars: 2 } }), + ).rejects.toThrow(/Auto or Ultra/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('sends budget, schema and metadata for a metered effort', async () => { + ok({ body: RUN }); + await runAction({ + action: createAgentRunAction, + propsValue: { + query: 'q', + effort: 'auto', + maxCostDollars: 3, + outputSchema: '{"type":"object"}', + metadata: { flow: 'x', empty: '' }, + previousRunId: 'agent_run_00', + }, + }); + expect(lastRequest().body).toEqual({ + query: 'q', + effort: 'auto', + outputSchema: { type: 'object' }, + budget: { maxCostDollars: 3 }, + previousRunId: 'agent_run_00', + metadata: { flow: 'x' }, + }); + }); + + it('rejects an unknown effort', async () => { + await expect(runAction({ action: createAgentRunAction, propsValue: { query: 'q', effort: 'max' } })).rejects.toThrow( + /Effort must be one of/, + ); + }); + + it('flattens a run with citations per grounding entry', async () => { + ok({ body: RUN }); + const result = await runAction({ action: getAgentRunAction, propsValue: { runId: ' agent_run_01 ' } }); + expect(lastRequest().url).toBe('https://api.exa.ai/agent/runs/agent_run_01'); + expect(result).toEqual({ + id: 'agent_run_01', + status: 'completed', + stop_reason: 'schema_satisfied', + created_at: '2026-09-29T10:00:00.000Z', + completed_at: '2026-09-29T10:01:00.000Z', + query: 'q', + effort: 'low', + output_text: 'answer', + output_structured: { companies: [] }, + citations: [ + { field: 'companies', url: 'https://a.com', title: 'A', confidence: 'high' }, + { field: 'companies', url: 'https://b.com', title: null, confidence: 'high' }, + ], + search_count: 3, + agent_compute_units: 1, + cost_total: 0.025, + }); + }); + + it.each([ + ['get', getAgentRunAction], + ['cancel', cancelAgentRunAction], + ])('%s refuses a blank run id instead of calling the list route', async (_name, action) => { + await expect(runAction({ action, propsValue: { runId: ' ' } })).rejects.toThrow(/Run ID is required/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('cancel posts to the cancel route', async () => { + ok({ body: { ...RUN, status: 'cancelled' } }); + await runAction({ action: cancelAgentRunAction, propsValue: { runId: 'agent_run_01' } }); + expect(lastRequest().method).toBe('POST'); + expect(lastRequest().url).toBe('https://api.exa.ai/agent/runs/agent_run_01/cancel'); + }); + + it('list returns one page with the cursor, not a total', async () => { + ok({ body: { object: 'list', data: [RUN], hasMore: true, nextCursor: 'agent_run_00' } }); + const result = await runAction({ action: listAgentRunsAction, propsValue: { limit: 1, cursor: '' } }); + expect(lastRequest().queryParams).toEqual({ limit: '1' }); + expect(result).toMatchObject({ count: 1, has_more: true, next_cursor: 'agent_run_00' }); + }); + + it('maps the Agent API error shape into the message', async () => { + fail({ status: 429, body: { error: { type: 'rate_limit', code: 'CONCURRENCY_LIMIT', message: 'too many runs' } } }); + await expect(runAction({ action: getAgentRunAction, propsValue: { runId: 'x' } })).rejects.toThrow( + /429.*CONCURRENCY_LIMIT too many runs/, + ); + }); +}); + +const SECRET = 'whsec_fixture_secret'; +const STORE_KEY = 'exa_search_monitor'; +const BODY = JSON.stringify({ + id: 'event_1', + object: 'event', + type: 'monitor.run.completed', + data: { + id: 'run_1', + monitorId: 'mon_1', + status: 'completed', + output: { + results: [{ title: 'R', url: 'https://r.com', publishedDate: '2026-09-01' }], + content: 'summary', + grounding: [{ field: 'content', citations: [{ title: 'R', url: 'https://r.com' }], confidence: 'high' }], + }, + failReason: null, + metadata: null, + }, + createdAt: '2026-09-29T10:00:00.000Z', +}); + +const NOW_T = String(Math.floor(Date.now() / 1000)); + +function sign({ body, timestamp, secret }: { body: string; timestamp: string; secret: string }) { + const v1 = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + return `t=${timestamp},v1=${v1}`; +} + +describe('signature', () => { + it('accepts a signature built as t.rawBody', () => { + const header = sign({ body: BODY, timestamp: NOW_T, secret: SECRET }); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: header, rawBody: BODY })).toBe(true); + }); + + it('rejects a tampered body, a wrong secret, a parsed body and a garbage header', () => { + const header = sign({ body: BODY, timestamp: NOW_T, secret: SECRET }); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: header, rawBody: BODY.replace('R', 'X') })).toBe(false); + expect(exaWebhookSignature.verify({ secret: 'other', signatureHeader: header, rawBody: BODY })).toBe(false); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: header, rawBody: JSON.parse(BODY) })).toBe(false); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: 't=1,v1=zz', rawBody: BODY })).toBe(false); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: undefined, rawBody: BODY })).toBe(false); + }); + + it('accepts a correctly signed delivery only within 15 minutes, in seconds or milliseconds', () => { + const nowMs = Date.parse('2026-09-30T12:00:00Z'); + const at = ({ ms, unit }: { ms: number; unit: 's' | 'ms' }) => + sign({ body: BODY, timestamp: String(unit === 's' ? Math.floor(ms / 1000) : ms), secret: SECRET }); + const verifyAt = (header: string) => exaWebhookSignature.verify({ secret: SECRET, signatureHeader: header, rawBody: BODY, nowMs }); + expect(verifyAt(at({ ms: nowMs - 60_000, unit: 's' }))).toBe(true); + expect(verifyAt(at({ ms: nowMs - 60_000, unit: 'ms' }))).toBe(true); + expect(verifyAt(at({ ms: nowMs - 899_000, unit: 's' }))).toBe(true); + expect(verifyAt(at({ ms: nowMs - 899_000, unit: 'ms' }))).toBe(true); + expect(verifyAt(at({ ms: nowMs - 901_000, unit: 's' }))).toBe(false); + expect(verifyAt(at({ ms: nowMs - 901_000, unit: 'ms' }))).toBe(false); + expect(verifyAt(at({ ms: nowMs - 16 * 60_000, unit: 's' }))).toBe(false); + expect(verifyAt(at({ ms: nowMs + 16 * 60_000, unit: 's' }))).toBe(false); + }); + + it('still accepts a delivery that waited 6 minutes in the Activepieces job queue', () => { + const nowMs = Date.parse('2026-09-30T12:00:00Z'); + const header = sign({ body: BODY, timestamp: String(Math.floor((nowMs - 6 * 60_000) / 1000)), secret: SECRET }); + expect(exaWebhookSignature.verify({ secret: SECRET, signatureHeader: header, rawBody: BODY, nowMs })).toBe(true); + }); +}); + +describe('new_search_monitor_results trigger', () => { + const newSearchMonitorResultsTrigger = asWebhook(monitorTrigger); + const STATE = { [STORE_KEY]: { monitorId: 'mon_1', webhookSecret: SECRET } }; + + it('creates a monitor pointed at the webhook URL and stores its id and secret', async () => { + const { store, data } = memoryStore(); + ok({ status: 201, body: { id: 'mon_1', webhookSecret: SECRET } }); + await newSearchMonitorResultsTrigger.onEnable( + webhookContext({ + store, + propsValue: { query: 'battery recycling', period: '1d', numResults: 5, includeHighlights: true }, + }), + ); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().url).toBe('https://api.exa.ai/monitors'); + expect(lastRequest().body).toEqual({ + name: 'Activepieces flow test-flow-id', + search: { query: 'battery recycling', numResults: 5, contents: { highlights: true } }, + trigger: { type: 'interval', period: '1d' }, + webhook: { url: 'https://hooks.example.com/abc', events: ['monitor.run.completed'] }, + }); + expect(data[STORE_KEY]).toEqual({ monitorId: 'mon_1', webhookSecret: SECRET, stale: [] }); + }); + + it('refuses a non-HTTPS webhook URL before creating a monitor', async () => { + const { store } = memoryStore(); + await expect( + newSearchMonitorResultsTrigger.onEnable( + webhookContext({ store, webhookUrl: 'http://localhost:4200/api/v1/webhooks/abc', propsValue: { query: 'q', period: '1d' } }), + ), + ).rejects.toThrow(/public HTTPS address/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('deletes the monitor and fails when Exa returns no webhook secret', async () => { + const { store, data } = memoryStore(); + ok({ status: 201, body: { id: 'mon_1' } }); + ok({ body: {} }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/without a webhook secret/); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(lastRequest().method).toBe('DELETE'); + expect(lastRequest().url).toBe('https://api.exa.ai/monitors/mon_1'); + expect(data[STORE_KEY]).toBeUndefined(); + }); + + it('creates the replacement before deleting the monitor left from a failed disable', async () => { + const { store, data } = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old' } }); + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + ok({ body: {} }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(sendRequest.mock.calls[0][0]).toMatchObject({ method: 'POST', url: 'https://api.exa.ai/monitors' }); + expect(sendRequest.mock.calls[1][0]).toMatchObject({ method: 'DELETE', url: 'https://api.exa.ai/monitors/mon_old' }); + expect(data[STORE_KEY]).toEqual({ monitorId: 'mon_new', webhookSecret: SECRET, stale: [] }); + }); + + it('keeps the existing monitor and its secret when creating the replacement fails', async () => { + const { store, data } = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old' } }); + fail({ status: 500 }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/500/); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest().method).toBe('POST'); + expect(data[STORE_KEY]).toEqual({ monitorId: 'mon_old', webhookSecret: 'old' }); + }); + + it('remembers a leftover monitor it could not delete and removes it on the next disable', async () => { + const { store, data } = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old' } }); + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + fail({ status: 403 }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(data[STORE_KEY]).toEqual({ monitorId: 'mon_new', webhookSecret: SECRET, stale: ['mon_old'] }); + + sendRequest.mockReset(); + ok({ body: {} }); + ok({ body: {} }); + await newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(sendRequest.mock.calls.map(([request]) => request.url)).toEqual([ + 'https://api.exa.ai/monitors/mon_new', + 'https://api.exa.ai/monitors/mon_old', + ]); + expect(data[STORE_KEY]).toBeUndefined(); + }); + + it('keeps the old monitor ID when its delete fails and the cleanup write fails too', async () => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: [] } }); + let puts = 0; + const store: Store = { + ...memory.store, + put: async (key, value) => { + puts += 1; + if (puts === 2) { + throw new Error('store down'); + } + return memory.store.put(key, value); + }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + fail({ status: 403 }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + warn.mockRestore(); + expect(puts).toBe(2); + expect(memory.data[STORE_KEY]).toEqual({ monitorId: 'mon_new', webhookSecret: SECRET, stale: ['mon_old'] }); + }); + + it.each([1, 2])('never loses a monitor ID when store write %i of an enable fails', async (failingPut) => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: ['mon_older'] } }); + let puts = 0; + const store: Store = { + ...memory.store, + put: async (key, value) => { + puts += 1; + if (puts === failingPut) { + throw new Error('store down'); + } + return memory.store.put(key, value); + }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + fail({ status: 403 }); + fail({ status: 403 }); + fail({ status: 403 }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })).catch(() => undefined); + warn.mockRestore(); + const deleted = sendRequest.mock.calls + .map(([request]) => request) + .filter((request) => request.method === 'DELETE') + .map((request) => String(request.url).split('/').pop()); + const state = memory.data[STORE_KEY]; + const reachable = [state?.monitorId, ...(state?.stale ?? [])]; + for (const id of ['mon_old', 'mon_older']) { + expect(reachable).toContain(id); + } + if (!reachable.includes('mon_new')) { + expect(deleted).toContain('mon_new'); + } + }); + + it('records the new monitor for cleanup when Exa sends no secret and the rollback delete fails', async () => { + const { store, data } = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: [] } }); + ok({ status: 201, body: { id: 'mon_new' } }); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/mon_new could not be deleted/); + expect(data[STORE_KEY]).toEqual({ monitorId: 'mon_old', webhookSecret: 'old', stale: ['mon_new'] }); + }); + + it('names the new monitor when Exa sends no secret, the rollback fails and the cleanup write fails', async () => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: [] } }); + const store: Store = { ...memory.store, put: async () => { throw new Error('store down'); } }; + ok({ status: 201, body: { id: 'mon_new' } }); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/store down.*mon_new could not be deleted/); + }); + + it('names the new monitor when saving it fails and the rollback fails', async () => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: [] } }); + const store: Store = { ...memory.store, put: async () => { throw new Error('store down'); } }; + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/store down.*mon_new could not be deleted/); + expect(memory.data[STORE_KEY]).toEqual({ monitorId: 'mon_old', webhookSecret: 'old', stale: [] }); + }); + + it('names the monitor when a disable cannot delete it', async () => { + const { store, data } = memoryStore(STATE); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/Could not delete the Exa monitor mon_1/); + expect(data[STORE_KEY]).toMatchObject({ monitorId: 'mon_1' }); + }); + + it('names the monitors still to delete when the cleanup list cannot be saved', async () => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_old', webhookSecret: 'old', stale: [] } }); + let puts = 0; + const store: Store = { + ...memory.store, + put: async (key, value) => { + puts += 1; + if (puts === 2) { + throw new Error('store down'); + } + return memory.store.put(key, value); + }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + ok({ status: 201, body: { id: 'mon_new', webhookSecret: SECRET } }); + fail({ status: 403 }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/Monitors still to delete: mon_old/); + warn.mockRestore(); + }); + + it('keeps every monitor ID when a disable cannot finish its cleanup', async () => { + const memory = memoryStore({ [STORE_KEY]: { monitorId: 'mon_1', webhookSecret: SECRET, stale: ['mon_old'] } }); + const store: Store = { + ...memory.store, + put: async () => { + throw new Error('store down'); + }, + delete: async () => { + throw new Error('store down'); + }, + }; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + ok({ body: {} }); + fail({ status: 403 }); + await newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + warn.mockRestore(); + expect(memory.data[STORE_KEY]).toEqual({ monitorId: 'mon_1', webhookSecret: SECRET, stale: ['mon_old'] }); + }); + + it('names the monitor to delete by hand when the rollback delete fails', async () => { + const { store } = memoryStore(); + ok({ status: 201, body: { id: 'mon_1' } }); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/mon_1 could not be deleted/); + }); + + it('deletes the monitor it created if storing the id fails', async () => { + const { store, failNextPut } = memoryStore(); + failNextPut(new Error('store down')); + ok({ status: 201, body: { id: 'mon_1', webhookSecret: SECRET } }); + ok({ body: {} }); + await expect( + newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1h' } })), + ).rejects.toThrow(/store down/); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(lastRequest().method).toBe('DELETE'); + expect(lastRequest().url).toBe('https://api.exa.ai/monitors/mon_1'); + }); + + it('forgets the monitor on disable, treating 404 as already deleted', async () => { + const { store, data } = memoryStore(STATE); + fail({ status: 404 }); + await newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(data[STORE_KEY]).toBeUndefined(); + }); + + it('keeps the monitor id when the delete is refused, without retrying', async () => { + const { store, data } = memoryStore(STATE); + fail({ status: 403 }); + await expect( + newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(data[STORE_KEY]).toBeDefined(); + }); + + it('retries the delete on 429 and 5xx before forgetting the monitor', async () => { + vi.useFakeTimers(); + const { store, data } = memoryStore(STATE); + fail({ status: 429 }); + fail({ status: 503 }); + ok({ body: {} }); + const disabling = newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + await vi.runAllTimersAsync(); + await disabling; + vi.useRealTimers(); + expect(sendRequest).toHaveBeenCalledTimes(3); + expect(sendRequest.mock.calls.every(([request]) => request.method === 'DELETE')).toBe(true); + expect(data[STORE_KEY]).toBeUndefined(); + }); + + it('gives up after 3 delete attempts and keeps the monitor id', async () => { + const { store, data } = memoryStore(STATE); + fail({ status: 500 }); + fail({ status: 500 }); + fail({ status: 500 }); + vi.useFakeTimers(); + const disabling = expect( + newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })), + ).rejects.toThrow(/500/); + await vi.runAllTimersAsync(); + await disabling; + vi.useRealTimers(); + expect(sendRequest).toHaveBeenCalledTimes(3); + expect(data[STORE_KEY]).toMatchObject({ monitorId: 'mon_1' }); + }); + + it('builder Test runs one search, maps it to the run shape and never creates a monitor', async () => { + const { store, data } = memoryStore(); + ok({ body: { results: [{ id: 'u', title: 'T', url: 'https://t.com', publishedDate: '2026-09-28 00:00:00.000000000', highlights: ['a', 'b'] }] } }); + const result = await newSearchMonitorResultsTrigger.test( + webhookContext({ store, propsValue: { query: 'q', period: '1d', numResults: 3, includeDomains: ['t.com'], includeHighlights: true } }), + ); + expect(monitorTrigger.testStrategy).toBe('TEST_FUNCTION'); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(lastRequest()).toMatchObject({ method: 'POST', url: 'https://api.exa.ai/search' }); + expect(lastRequest().body).toEqual({ query: 'q', numResults: 3, includeDomains: ['t.com'], contents: { highlights: true } }); + expect(result).toEqual([ + expect.objectContaining({ + status: 'completed', + result_count: 1, + results: [expect.objectContaining({ url: 'https://t.com', published_date: '2026-09-28T00:00:00.000Z', highlights: 'a\nb' })], + }), + ]); + expect(data).toEqual({}); + }); + + it('emits one flattened run for a correctly signed delivery and dedupes a retry', async () => { + const { store } = memoryStore(STATE); + const payload = { + body: JSON.parse(BODY), + rawBody: BODY, + headers: { 'Exa-Signature': sign({ body: BODY, timestamp: NOW_T, secret: SECRET }) }, + queryParams: {}, + }; + const context = webhookContext({ store, payload, propsValue: { query: 'q', period: '1d' } }); + const first = await newSearchMonitorResultsTrigger.run(context); + expect(first).toEqual([ + expect.objectContaining({ + event_id: 'event_1', + run_id: 'run_1', + monitor_id: 'mon_1', + status: 'completed', + summary: 'summary', + result_count: 1, + results: [expect.objectContaining({ title: 'R', url: 'https://r.com', published_date: '2026-09-01' })], + citations: [{ field: 'content', url: 'https://r.com', title: 'R', confidence: 'high' }], + }), + ]); + const retry = await newSearchMonitorResultsTrigger.run(context); + expect(retry).toEqual([]); + }); + + it.each([ + ['another monitor', { monitorId: 'mon_other' }, 'monitor.run.completed'], + ['another event type', {}, 'monitor.run.created'], + ])('drops a signed delivery for %s', async (_name, dataPatch, type) => { + const { store } = memoryStore(STATE); + const parsed = JSON.parse(BODY); + const body = JSON.stringify({ ...parsed, type, data: { ...parsed.data, ...dataPatch } }); + const result = await newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { + body: JSON.parse(body), + rawBody: body, + headers: { 'exa-signature': sign({ body, timestamp: NOW_T, secret: SECRET }) }, + queryParams: {}, + }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + expect(result).toEqual([]); + }); + + it('does not re-emit results already delivered, also after a republish', async () => { + const { store, data } = memoryStore(STATE); + const deliver = async ({ eventId, monitorId, urls }: { eventId: string; monitorId: string; urls: string[] }) => { + const parsed = JSON.parse(BODY); + const body = JSON.stringify({ + ...parsed, + id: eventId, + data: { ...parsed.data, id: `run_${eventId}`, monitorId, output: { ...parsed.data.output, results: urls.map((url) => ({ title: url, url })) } }, + }); + return newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { body: JSON.parse(body), rawBody: body, headers: { 'exa-signature': sign({ body, timestamp: NOW_T, secret: SECRET }) }, queryParams: {} }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + }; + const first = await deliver({ eventId: 'e1', monitorId: 'mon_1', urls: ['https://a.com', 'https://b.com'] }); + expect(first).toEqual([expect.objectContaining({ result_count: 2 })]); + + ok({ body: {} }); + await newSearchMonitorResultsTrigger.onDisable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + ok({ status: 201, body: { id: 'mon_2', webhookSecret: SECRET } }); + await newSearchMonitorResultsTrigger.onEnable(webhookContext({ store, propsValue: { query: 'q', period: '1d' } })); + expect(data[STORE_KEY]).toMatchObject({ monitorId: 'mon_2' }); + + const replayed = await deliver({ eventId: 'e2', monitorId: 'mon_2', urls: ['https://a.com', 'https://b.com'] }); + expect(replayed).toEqual([]); + const mixed = await deliver({ eventId: 'e3', monitorId: 'mon_2', urls: ['https://b.com', 'https://c.com'] }); + expect(mixed).toEqual([ + expect.objectContaining({ result_count: 1, results: [expect.objectContaining({ url: 'https://c.com' })] }), + ]); + }); + + it('keeps nothing when saving the dedupe state fails, so Exa\'s retry is still processed', async () => { + const memory = memoryStore(STATE); + let failSeenWrite = true; + const store: Store = { + ...memory.store, + put: async (key, value) => { + if (failSeenWrite && key.startsWith('exa_search_monitor_seen')) { + failSeenWrite = false; + throw new Error('store down'); + } + return memory.store.put(key, value); + }, + }; + const payload = { + body: JSON.parse(BODY), + rawBody: BODY, + headers: { 'exa-signature': sign({ body: BODY, timestamp: NOW_T, secret: SECRET }) }, + queryParams: {}, + }; + const context = webhookContext({ store, payload, propsValue: { query: 'q', period: '1d' } }); + await expect(newSearchMonitorResultsTrigger.run(context)).rejects.toThrow(/store down/); + const retry = await newSearchMonitorResultsTrigger.run(context); + expect(retry).toEqual([expect.objectContaining({ event_id: 'event_1', result_count: 1 })]); + }); + + it('logs why a correctly signed but stale delivery was dropped', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { store } = memoryStore(STATE); + const old = String(Math.floor(Date.now() / 1000) - 3600); + const result = await newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { body: JSON.parse(BODY), rawBody: BODY, headers: { 'exa-signature': sign({ body: BODY, timestamp: old, secret: SECRET }) }, queryParams: {} }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + expect(result).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/signed more than 15 minutes ago/); + warn.mockRestore(); + }); + + it('sends only the new results of a mixed run, with their citations and without the run-wide summary', async () => { + const { store } = memoryStore({ ...STATE, exa_search_monitor_seen: { eventIds: [], urls: ['https://a.com'] } }); + const parsed = JSON.parse(BODY); + const body = JSON.stringify({ + ...parsed, + id: 'event_mixed', + data: { + ...parsed.data, + output: { + results: [ + { title: 'A', url: 'https://a.com' }, + { title: 'B', url: 'https://b.com' }, + ], + content: 'A launched X [1]; B launched Y [2].', + grounding: [ + { field: 'content', citations: [{ title: 'A', url: 'https://a.com' }, { title: 'B', url: 'https://b.com' }], confidence: 'high' }, + ], + }, + }, + }); + const result = await newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { body: JSON.parse(body), rawBody: body, headers: { 'exa-signature': sign({ body, timestamp: NOW_T, secret: SECRET }) }, queryParams: {} }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + expect(result).toEqual([ + expect.objectContaining({ + result_count: 1, + results: [expect.objectContaining({ url: 'https://b.com' })], + citations: [{ field: 'content', url: 'https://b.com', title: 'B', confidence: 'high' }], + run_citations: [ + { field: 'content', url: 'https://a.com', title: 'A', confidence: 'high' }, + { field: 'content', url: 'https://b.com', title: 'B', confidence: 'high' }, + ], + summary: null, + run_summary: 'A launched X [1]; B launched Y [2].', + run_result_count: 2, + }), + ]); + }); + + it('keeps the summary when every result in the run is new', async () => { + const { store } = memoryStore(STATE); + const payload = { body: JSON.parse(BODY), rawBody: BODY, headers: { 'exa-signature': sign({ body: BODY, timestamp: NOW_T, secret: SECRET }) }, queryParams: {} }; + const result = await newSearchMonitorResultsTrigger.run(webhookContext({ store, payload, propsValue: { query: 'q', period: '1d' } })); + expect(result).toEqual([expect.objectContaining({ summary: 'summary', run_summary: 'summary', run_result_count: 1, result_count: 1 })]); + }); + + it('normalizes the monitor published date to ISO 8601', async () => { + const { store } = memoryStore(STATE); + const parsed = JSON.parse(BODY); + const body = JSON.stringify({ + ...parsed, + data: { ...parsed.data, output: { ...parsed.data.output, results: [{ title: 'R', url: 'https://r.com', publishedDate: '2026-09-28 00:00:00.000000000' }] } }, + }); + const result = await newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { body: JSON.parse(body), rawBody: body, headers: { 'exa-signature': sign({ body, timestamp: NOW_T, secret: SECRET }) }, queryParams: {} }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + expect(result).toEqual([expect.objectContaining({ results: [expect.objectContaining({ published_date: '2026-09-28T00:00:00.000Z' })] })]); + }); + + it('drops an unsigned delivery', async () => { + const { store } = memoryStore(STATE); + const result = await newSearchMonitorResultsTrigger.run( + webhookContext({ + store, + payload: { body: JSON.parse(BODY), rawBody: BODY, headers: {}, queryParams: {} }, + propsValue: { query: 'q', period: '1d' }, + }), + ); + expect(result).toEqual([]); + }); +}); diff --git a/packages/pieces/community/exa/test/helpers.ts b/packages/pieces/community/exa/test/helpers.ts new file mode 100644 index 000000000000..c3e4c44212f7 --- /dev/null +++ b/packages/pieces/community/exa/test/helpers.ts @@ -0,0 +1,122 @@ +import { + ActionContext, + AppConnectionType, + AppConnectionValueForAuthProperty, + InputPropertyMap, + StaticPropsValue, + Store, + TestOrRunHookContext, + TriggerHookContext, + TriggerStrategy, + createMockActionContext, +} from '@activepieces/pieces-framework'; +import { vi } from 'vitest'; +import type { exaAuth } from '../src/lib/auth'; + +export const sendRequest = vi.fn(); + +export function mockHttpClient() { + return { + httpClient: { sendRequest: (...args: unknown[]) => sendRequest(...args) }, + }; +} + +export const AUTH: AppConnectionValueForAuthProperty = { + type: AppConnectionType.SECRET_TEXT, + secret_text: 'exa_test_key', +}; + +export function ok({ body, status = 200 }: { body: unknown; status?: number }) { + sendRequest.mockResolvedValueOnce({ status, headers: {}, body }); +} + +export function fail({ status, body = { error: 'nope' } }: { status: number; body?: unknown }) { + sendRequest.mockRejectedValueOnce(Object.assign(new Error(`status ${status}`), { response: { status, body } })); +} + +export function lastRequest() { + return sendRequest.mock.calls[sendRequest.mock.calls.length - 1][0]; +} + +export function runAction({ + action, + propsValue, +}: { + action: { props: Props; run: (context: ActionContext) => Promise }; + propsValue: StaticPropsValue; +}): Promise { + const base = createMockActionContext({ propsValue }); + return action.run({ ...base, auth: AUTH }); +} + +export function memoryStore(initial: Record = {}): { + store: Store; + data: Record; + failNextPut: (error: Error) => void; +} { + const data: Record = { ...initial }; + let nextPutError: Error | undefined; + const store: Store = { + put: async (key, value) => { + if (nextPutError) { + const error = nextPutError; + nextPutError = undefined; + throw error; + } + data[key] = value; + return value; + }, + get: async (key) => { + const value = data[key]; + return value === undefined ? null : JSON.parse(JSON.stringify(value)); + }, + delete: async (key) => { + delete data[key]; + }, + }; + return { + store, + data, + failNextPut: (error) => { + nextPutError = error; + }, + }; +} + +export function asWebhook(trigger: T): Extract { + if (!isWebhook(trigger)) { + throw new Error('not a webhook trigger'); + } + return trigger; +} + +export function webhookContext({ + propsValue, + store, + payload = { body: {}, headers: {}, queryParams: {} }, + webhookUrl = 'https://hooks.example.com/abc', +}: { + propsValue: StaticPropsValue; + store: Store; + payload?: TestOrRunHookContext['payload']; + webhookUrl?: string; +}): TestOrRunHookContext { + const base = createMockActionContext({ propsValue }); + const context: TriggerHookContext = { + auth: AUTH, + propsValue, + store, + project: base.project, + flows: base.flows, + step: base.step, + connections: base.connections, + server: base.server, + webhookUrl, + payload, + }; + return { ...context, files: base.files }; +} + +function isWebhook(trigger: T): trigger is Extract { + return trigger.type === TriggerStrategy.WEBHOOK; +} diff --git a/packages/pieces/community/exa/vitest.config.ts b/packages/pieces/community/exa/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/exa/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + 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'), + }, + }, +}) diff --git a/packages/pieces/community/ghostcms/package.json b/packages/pieces/community/ghostcms/package.json index 4424388768bb..fdbea9912b7d 100644 --- a/packages/pieces/community/ghostcms/package.json +++ b/packages/pieces/community/ghostcms/package.json @@ -1,22 +1,25 @@ { "name": "@activepieces/piece-ghostcms", - "version": "0.2.1", + "version": "0.3.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "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" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", "jsonwebtoken": "9.0.1", "@activepieces/core-piece-types": "workspace:*", - "@activepieces/core-utils": "workspace:*" + "@activepieces/core-utils": "workspace:*", + "form-data": "4.0.6" }, "devDependencies": { "@types/jsonwebtoken": "9.0.10", - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/ghostcms/src/i18n/translation.json b/packages/pieces/community/ghostcms/src/i18n/translation.json index f333f747720e..07629de74074 100644 --- a/packages/pieces/community/ghostcms/src/i18n/translation.json +++ b/packages/pieces/community/ghostcms/src/i18n/translation.json @@ -10,12 +10,135 @@ "Create Post": "Create Post", "Find Member": "Find Member", "Find User": "Find User", + "List Posts": "List Posts", + "Get Post": "Get Post", + "Get Post by Slug": "Get Post by Slug", + "Update Post": "Update Post", + "Publish Post": "Publish Post", + "Schedule Post": "Schedule Post", + "Unpublish Post": "Unpublish Post", + "Copy Post": "Copy Post", + "Delete Post": "Delete Post", + "List Pages": "List Pages", + "Get Page": "Get Page", + "Get Page by Slug": "Get Page by Slug", + "Create Page": "Create Page", + "Update Page": "Update Page", + "Copy Page": "Copy Page", + "Delete Page": "Delete Page", + "List Tags": "List Tags", + "Get Tag": "Get Tag", + "Get Tag by Slug": "Get Tag by Slug", + "Create Tag": "Create Tag", + "Update Tag": "Update Tag", + "Delete Tag": "Delete Tag", + "List Members": "List Members", + "Get Member": "Get Member", + "Get Member by Email": "Get Member by Email", + "Delete Member": "Delete Member", + "Add Member Label": "Add Member Label", + "Remove Member Label": "Remove Member Label", + "Subscribe Member to Newsletters": "Subscribe Member to Newsletters", + "Unsubscribe Member from Newsletters": "Unsubscribe Member from Newsletters", + "List Labels": "List Labels", + "Get Label": "Get Label", + "Create Label": "Create Label", + "Update Label": "Update Label", + "Delete Label": "Delete Label", + "List Newsletters": "List Newsletters", + "Get Newsletter": "Get Newsletter", + "Create Newsletter": "Create Newsletter", + "Update Newsletter": "Update Newsletter", + "Archive Newsletter": "Archive Newsletter", + "List Tiers": "List Tiers", + "Get Tier": "Get Tier", + "Create Tier": "Create Tier", + "Update Tier": "Update Tier", + "Archive Tier": "Archive Tier", + "List Offers": "List Offers", + "Get Offer": "Get Offer", + "Create Offer": "Create Offer", + "Update Offer": "Update Offer", + "Archive Offer": "Archive Offer", + "List Users": "List Users", + "Get User": "Get User", + "Get User by Email": "Get User by Email", + "Upload Image": "Upload Image", + "Get Site": "Get Site", + "Upload Media": "Upload Media", + "Upload File": "Upload File", + "Get Settings": "Get Settings", + "List Member Activity": "List Member Activity", + "Get Member Stats": "Get Member Stats", "Custom API Call": "Custom API Call", "Create a new member": "Create a new member", "Update a member": "Update a member", "Create a new post": "Create a new post", "Find a member by email": "Find a member by email", "Find a staff user by email": "Find a staff user by email", + "List or search posts with an optional filter.": "List or search posts with an optional filter.", + "Get a post by ID, including its HTML content.": "Get a post by ID, including its HTML content.", + "Get a post by its URL slug.": "Get a post by its URL slug.", + "Create a post as a draft, published or scheduled.": "Create a post as a draft, published or scheduled.", + "Update the content or settings of a post.": "Update the content or settings of a post.", + "Publish a draft post now, optionally emailing it to a newsletter.": "Publish a draft post now, optionally emailing it to a newsletter.", + "Schedule a draft post to publish at a future time.": "Schedule a draft post to publish at a future time.", + "Move a published or scheduled post back to draft.": "Move a published or scheduled post back to draft.", + "Duplicate a post as a new draft.": "Duplicate a post as a new draft.", + "Permanently delete a post.": "Permanently delete a post.", + "List or search pages with an optional filter.": "List or search pages with an optional filter.", + "Get a page by ID, including its HTML content.": "Get a page by ID, including its HTML content.", + "Get a page by its URL slug.": "Get a page by its URL slug.", + "Create a static page as a draft, published or scheduled.": "Create a static page as a draft, published or scheduled.", + "Update the content, settings or status of a page.": "Update the content, settings or status of a page.", + "Duplicate a page as a new draft.": "Duplicate a page as a new draft.", + "Permanently delete a page.": "Permanently delete a page.", + "List or search post tags.": "List or search post tags.", + "Get a tag by ID.": "Get a tag by ID.", + "Get a tag by its slug.": "Get a tag by its slug.", + "Create a post tag.": "Create a post tag.", + "Update the name or settings of a tag.": "Update the name or settings of a tag.", + "Permanently delete a tag.": "Permanently delete a tag.", + "List or search members.": "List or search members.", + "Get a member by ID.": "Get a member by ID.", + "Find a member by exact email address.": "Find a member by exact email address.", + "Add a new member to the publication.": "Add a new member to the publication.", + "Update the details, labels or newsletters of a member.": "Update the details, labels or newsletters of a member.", + "Permanently delete a member.": "Permanently delete a member.", + "Add one label to a member, keeping their other labels.": "Add one label to a member, keeping their other labels.", + "Remove one label from a member, keeping their other labels.": "Remove one label from a member, keeping their other labels.", + "Add newsletter subscriptions to a member, keeping existing ones.": "Add newsletter subscriptions to a member, keeping existing ones.", + "Remove newsletter subscriptions from a member.": "Remove newsletter subscriptions from a member.", + "List member labels.": "List member labels.", + "Get a member label by ID.": "Get a member label by ID.", + "Create a member label.": "Create a member label.", + "Rename a member label.": "Rename a member label.", + "Permanently delete a member label.": "Permanently delete a member label.", + "List the newsletters of the publication.": "List the newsletters of the publication.", + "Get a newsletter by ID.": "Get a newsletter by ID.", + "Create a new newsletter.": "Create a new newsletter.", + "Update the name or settings of a newsletter.": "Update the name or settings of a newsletter.", + "Archive a newsletter so it can no longer be sent or subscribed to.": "Archive a newsletter so it can no longer be sent or subscribed to.", + "List membership tiers with prices and benefits.": "List membership tiers with prices and benefits.", + "Get a membership tier by ID.": "Get a membership tier by ID.", + "Create a paid membership tier.": "Create a paid membership tier.", + "Update the prices, benefits or settings of a tier.": "Update the prices, benefits or settings of a tier.", + "Archive a paid tier so new members can no longer buy it.": "Archive a paid tier so new members can no longer buy it.", + "List discount and trial offers.": "List discount and trial offers.", + "Get an offer by ID.": "Get an offer by ID.", + "Create a discount or free-trial offer for a paid tier.": "Create a discount or free-trial offer for a paid tier.", + "Update the name, code or display text of an offer.": "Update the name, code or display text of an offer.", + "Archive an offer so it can no longer be redeemed.": "Archive an offer so it can no longer be redeemed.", + "List staff users such as authors and editors.": "List staff users such as authors and editors.", + "Get a staff user by ID.": "Get a staff user by ID.", + "Find a staff user by exact email address.": "Find a staff user by exact email address.", + "Upload an image to Ghost and get its URL.": "Upload an image to Ghost and get its URL.", + "Get the site title, URL and Ghost version.": "Get the site title, URL and Ghost version.", + "Upload a video or audio file to Ghost and get its URL.": "Upload a video or audio file to Ghost and get its URL.", + "Upload a downloadable file (PDF, ZIP, CSV and more) to Ghost and get its URL.": "Upload a downloadable file (PDF, ZIP, CSV and more) to Ghost and get its URL.", + "Get the publication settings: branding, SEO, navigation, timezone and membership options.": "Get the publication settings: branding, SEO, navigation, timezone and membership options.", + "List member activity events such as signups, logins, newsletter changes, email opens and payments.": "List member activity events such as signups, logins, newsletter changes, email opens and payments.", + "Get the current member totals (free, paid, comped) and the daily history.": "Get the current member totals (free, paid, comped) and the daily history.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "Email": "Email", "Full Name": "Full Name", @@ -31,30 +154,246 @@ "Author": "Author", "Featured": "Featured", "Tags": "Tags", + "Filter": "Filter", + "Limit": "Limit", + "Page": "Page", + "Order": "Order", + "Include Content": "Include Content", + "Post ID": "Post ID", + "Excerpt": "Excerpt", + "Feature Image URL": "Feature Image URL", + "Visibility": "Visibility", + "Authors": "Authors", + "Meta Title": "Meta Title", + "Meta Description": "Meta Description", + "Clear Fields": "Clear Fields", + "Newsletter Slug": "Newsletter Slug", + "Email Segment": "Email Segment", + "Email Only": "Email Only", + "Page ID": "Page ID", + "Tag ID": "Tag ID", + "Name": "Name", + "Description": "Description", + "Accent Color": "Accent Color", + "Search": "Search", + "Member ID": "Member ID", + "Labels": "Labels", + "Newsletter IDs": "Newsletter IDs", + "Cancel Stripe Subscriptions": "Cancel Stripe Subscriptions", + "Label ID": "Label ID", + "Unsubscribe from All": "Unsubscribe from All", + "Newsletter ID": "Newsletter ID", + "Sender Name": "Sender Name", + "Reply-To": "Reply-To", + "Audience": "Audience", + "Subscribe on Signup": "Subscribe on Signup", + "Opt In Existing Members": "Opt In Existing Members", + "Tier ID": "Tier ID", + "Monthly Price": "Monthly Price", + "Yearly Price": "Yearly Price", + "Currency": "Currency", + "Benefits": "Benefits", + "Welcome Page URL": "Welcome Page URL", + "Trial Days": "Trial Days", + "Offer ID": "Offer ID", + "Code": "Code", + "Cadence": "Cadence", + "Type": "Type", + "Amount": "Amount", + "Duration": "Duration", + "Duration in Months": "Duration in Months", + "Display Title": "Display Title", + "Display Description": "Display Description", + "User ID": "User ID", + "Image": "Image", + "Purpose": "Purpose", + "Reference": "Reference", + "Media File": "Media File", + "File": "File", + "Event Type": "Event Type", "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", + "A Ghost NQL filter, e.g. status:published+tag:news or title:~'launch'. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. status:published+tag:news or title:~'launch'. Quote values that contain spaces or special characters with single quotes.", + "How many records to return per page, from 1 to 100. Defaults to 15.": "How many records to return per page, from 1 to 100. Defaults to 15.", + "The page number to return. Defaults to 1.": "The page number to return. Defaults to 1.", + "Sort order as \" asc|desc\", e.g. published_at desc.": "Sort order as \" asc|desc\", e.g. published_at desc.", + "Return the HTML body of each post. Off by default to keep the result small.": "Return the HTML body of each post. Off by default to keep the result small.", + "The post ID, from List Posts.": "The post ID, from List Posts.", + "The post slug, e.g. \"welcome\" for https://example.com/welcome/.": "The post slug, e.g. \"welcome\" for https://example.com/welcome/.", + "The post title.": "The post title.", + "The post body as HTML. Ghost converts it to its editor format.": "The post body as HTML. Ghost converts it to its editor format.", + "The URL slug of the post. Ghost generates one from the title when empty.": "The URL slug of the post. Ghost generates one from the title when empty.", + "A short summary shown in lists and previews.": "A short summary shown in lists and previews.", + "The URL of the feature image, for example one returned by Upload Image.": "The URL of the feature image, for example one returned by Upload Image.", + "Mark the post as featured. Leave empty to keep the current value.": "Mark the post as featured. Leave empty to keep the current value.", + "Who can read the content on the site.": "Who can read the content on the site.", + "Tag names. Missing tags are created.": "Tag names. Missing tags are created.", + "Staff user IDs or emails, from List Users. Defaults to the integration owner.": "Staff user IDs or emails, from List Users. Defaults to the integration owner.", + "The SEO title.": "The SEO title.", + "The SEO description.": "The SEO description.", + "Draft (default), published now, or scheduled for Published At.": "Draft (default), published now, or scheduled for Published At.", + "Required when Status is Scheduled and must be in the future. Optional backdate for Published.": "Required when Status is Scheduled and must be in the future. Optional backdate for Published.", + "The post body as HTML. Ghost converts it to its editor format. Supplying it replaces the whole body.": "The post body as HTML. Ghost converts it to its editor format. Supplying it replaces the whole body.", + "Tag names. Missing tags are created. Supplying tags replaces the full tag list; an empty list removes every tag, and leaving it out keeps the current tags.": "Tag names. Missing tags are created. Supplying tags replaces the full tag list; an empty list removes every tag, and leaving it out keeps the current tags.", + "Staff user IDs or emails, from List Users. Supplying authors replaces the full author list. Ghost needs at least one author, so an empty list is rejected.": "Staff user IDs or emails, from List Users. Supplying authors replaces the full author list. Ghost needs at least one author, so an empty list is rejected.", + "Fields to blank out on the record. A field listed here must not also be given a new value. Leave empty to clear nothing.": "Fields to blank out on the record. A field listed here must not also be given a new value. Leave empty to clear nothing.", + "Send the post by email to this newsletter, e.g. \"default-newsletter\". Get slugs from List Newsletters. Leave empty to publish on the site only.": "Send the post by email to this newsletter, e.g. \"default-newsletter\". Get slugs from List Newsletters. Leave empty to publish on the site only.", + "Which members receive the email: \"all\", \"status:free\", \"status:-free\" (paid) or another member NQL filter. Only used with Newsletter Slug. Defaults to all.": "Which members receive the email: \"all\", \"status:free\", \"status:-free\" (paid) or another member NQL filter. Only used with Newsletter Slug. Defaults to all.", + "Send the post as an email to the newsletter without publishing it on the site. Requires Newsletter Slug.": "Send the post as an email to the newsletter without publishing it on the site. Requires Newsletter Slug.", + "When to publish. Must be in the future.": "When to publish. Must be in the future.", + "The ID of the post to copy, from List Posts.": "The ID of the post to copy, from List Posts.", + "A Ghost NQL filter, e.g. status:published or title:~'about'. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. status:published or title:~'about'. Quote values that contain spaces or special characters with single quotes.", + "Sort order as \" asc|desc\", e.g. updated_at desc.": "Sort order as \" asc|desc\", e.g. updated_at desc.", + "Return the HTML body of each page. Off by default to keep the result small.": "Return the HTML body of each page. Off by default to keep the result small.", + "The page ID, from List Pages.": "The page ID, from List Pages.", + "The page slug, e.g. \"about\" for https://example.com/about/.": "The page slug, e.g. \"about\" for https://example.com/about/.", + "The page title.": "The page title.", + "The page body as HTML. Ghost converts it to its editor format.": "The page body as HTML. Ghost converts it to its editor format.", + "The URL slug of the page. Ghost generates one from the title when empty.": "The URL slug of the page. Ghost generates one from the title when empty.", + "Mark the page as featured. Leave empty to keep the current value.": "Mark the page as featured. Leave empty to keep the current value.", + "Required when Status is Scheduled and must be in the future.": "Required when Status is Scheduled and must be in the future.", + "The page body as HTML. Ghost converts it to its editor format. Supplying it replaces the whole body.": "The page body as HTML. Ghost converts it to its editor format. Supplying it replaces the whole body.", + "Change the page status. Scheduled requires a future Published At. Leave empty to keep it.": "Change the page status. Scheduled requires a future Published At. Leave empty to keep it.", + "The publish time. Must be in the future when Status is Scheduled.": "The publish time. Must be in the future when Status is Scheduled.", + "The ID of the page to copy, from List Pages.": "The ID of the page to copy, from List Pages.", + "A Ghost NQL filter, e.g. visibility:public or name:~'news'. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. visibility:public or name:~'news'. Quote values that contain spaces or special characters with single quotes.", + "Sort order as \" asc|desc\", e.g. name asc.": "Sort order as \" asc|desc\", e.g. name asc.", + "The tag ID, from List Tags.": "The tag ID, from List Tags.", + "The tag slug, e.g. \"news\".": "The tag slug, e.g. \"news\".", + "The tag name. Start it with # to make an internal tag that is hidden from readers.": "The tag name. Start it with # to make an internal tag that is hidden from readers.", + "The URL slug. Ghost generates one from the name when empty.": "The URL slug. Ghost generates one from the name when empty.", + "The tag description shown on its archive page.": "The tag description shown on its archive page.", + "A hex color, e.g. #FF5733.": "A hex color, e.g. #FF5733.", + "The SEO title of the tag page.": "The SEO title of the tag page.", + "The SEO description of the tag page.": "The SEO description of the tag page.", + "A Ghost NQL filter, e.g. status:paid+label:vip. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. status:paid+label:vip. Quote values that contain spaces or special characters with single quotes.", + "Free-text search on member name and email.": "Free-text search on member name and email.", + "Sort order as \" asc|desc\", e.g. created_at desc.": "Sort order as \" asc|desc\", e.g. created_at desc.", + "The member ID, from List Members or Get Member by Email.": "The member ID, from List Members or Get Member by Email.", + "The member email address.": "The member email address.", + "The member full name.": "The member full name.", + "A private note about the member, visible only to staff.": "A private note about the member, visible only to staff.", + "Label names to apply. Missing labels are created.": "Label names to apply. Missing labels are created.", + "The newsletters to subscribe the member to, from List Newsletters.": "The newsletters to subscribe the member to, from List Newsletters.", + "The new email address.": "The new email address.", + "The new full name.": "The new full name.", + "The new private staff note.": "The new private staff note.", + "The complete list of label names the member should have. Missing labels are created, and an empty list removes every label.": "The complete list of label names the member should have. Missing labels are created, and an empty list removes every label.", + "The complete list of newsletter IDs the member should be subscribed to. An empty list unsubscribes the member from every newsletter.": "The complete list of newsletter IDs the member should be subscribed to. An empty list unsubscribes the member from every newsletter.", + "Also cancel the member paid Stripe subscriptions. Off by default.": "Also cancel the member paid Stripe subscriptions. Off by default.", + "The label ID, from List Labels.": "The label ID, from List Labels.", + "The label ID, from List Labels or Get Member.": "The label ID, from List Labels or Get Member.", + "The newsletters to add, from List Newsletters.": "The newsletters to add, from List Newsletters.", + "The newsletters to remove, from Get Member or List Newsletters.": "The newsletters to remove, from Get Member or List Newsletters.", + "Remove every newsletter subscription. Newsletter IDs are ignored when on.": "Remove every newsletter subscription. Newsletter IDs are ignored when on.", + "A Ghost NQL filter, e.g. name:~'vip'. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. name:~'vip'. Quote values that contain spaces or special characters with single quotes.", + "The label name.": "The label name.", + "The new label name.": "The new label name.", + "The new label slug.": "The new label slug.", + "A Ghost NQL filter, e.g. status:active. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. status:active. Quote values that contain spaces or special characters with single quotes.", + "Sort order as \" asc|desc\", e.g. sort_order asc.": "Sort order as \" asc|desc\", e.g. sort_order asc.", + "The newsletter ID, from List Newsletters.": "The newsletter ID, from List Newsletters.", + "The newsletter name.": "The newsletter name.", + "A description shown to members when they choose newsletters.": "A description shown to members when they choose newsletters.", + "The From name of the emails. Defaults to the site title.": "The From name of the emails. Defaults to the site title.", + "Where member replies go.": "Where member replies go.", + "Who can subscribe to the newsletter.": "Who can subscribe to the newsletter.", + "Automatically subscribe new members to this newsletter. Leave empty to keep the current value.": "Automatically subscribe new members to this newsletter. Leave empty to keep the current value.", + "Subscribe every existing member who is subscribed to at least one newsletter.": "Subscribe every existing member who is subscribed to at least one newsletter.", + "A Ghost NQL filter, e.g. type:paid+active:true. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. type:paid+active:true. Quote values that contain spaces or special characters with single quotes.", + "Sort order as \" asc|desc\", e.g. monthly_price asc.": "Sort order as \" asc|desc\", e.g. monthly_price asc.", + "The tier ID, from List Tiers.": "The tier ID, from List Tiers.", + "The tier name shown on the pricing page.": "The tier name shown on the pricing page.", + "A short description shown on the pricing page.": "A short description shown on the pricing page.", + "The monthly price in the smallest currency unit, e.g. 500 for 5.00 USD.": "The monthly price in the smallest currency unit, e.g. 500 for 5.00 USD.", + "The yearly price in the smallest currency unit, e.g. 5000 for 50.00 USD.": "The yearly price in the smallest currency unit, e.g. 5000 for 50.00 USD.", + "The three-letter currency code, e.g. usd. Required with a price.": "The three-letter currency code, e.g. usd. Required with a price.", + "The benefit lines shown on the pricing page.": "The benefit lines shown on the pricing page.", + "Whether the tier is shown on the public pricing page.": "Whether the tier is shown on the public pricing page.", + "Where new subscribers of this tier land after signup.": "Where new subscribers of this tier land after signup.", + "The free trial length in days. 0 for no trial.": "The free trial length in days. 0 for no trial.", + "The benefit lines shown on the pricing page. Supplying benefits replaces the full list, and an empty list removes every benefit.": "The benefit lines shown on the pricing page. Supplying benefits replaces the full list, and an empty list removes every benefit.", + "The offer ID, from List Offers.": "The offer ID, from List Offers.", + "The internal offer name.": "The internal offer name.", + "The code used in the offer URL, e.g. black-friday.": "The code used in the offer URL, e.g. black-friday.", + "The paid tier the offer applies to, from List Tiers.": "The paid tier the offer applies to, from List Tiers.", + "Whether the offer applies to the monthly or the yearly price.": "Whether the offer applies to the monthly or the yearly price.", + "Percentage discount, fixed amount off, or a free trial.": "Percentage discount, fixed amount off, or a free trial.", + "Percent: 1 to 100. Fixed: the amount off in the smallest currency unit (500 = 5.00). Trial: the number of free days.": "Percent: 1 to 100. Fixed: the amount off in the smallest currency unit (500 = 5.00). Trial: the number of free days.", + "How long a percent or fixed discount lasts. Ignored for trials.": "How long a percent or fixed discount lasts. Ignored for trials.", + "Required when Duration is Several months.": "Required when Duration is Several months.", + "The three-letter currency code of a fixed discount, e.g. usd.": "The three-letter currency code of a fixed discount, e.g. usd.", + "The headline shown to visitors on the offer page.": "The headline shown to visitors on the offer page.", + "The text shown to visitors on the offer page.": "The text shown to visitors on the offer page.", + "The new internal name.": "The new internal name.", + "The new offer code.": "The new offer code.", + "The new headline shown on the offer page.": "The new headline shown on the offer page.", + "The new text shown on the offer page.": "The new text shown on the offer page.", + "The staff user ID, from List Users.": "The staff user ID, from List Users.", + "The staff user email address.": "The staff user email address.", + "The image file to upload.": "The image file to upload.", + "How Ghost validates the image. Defaults to a content image.": "How Ghost validates the image. Defaults to a content image.", + "An optional value returned unchanged, e.g. the original file name.": "An optional value returned unchanged, e.g. the original file name.", + "The video or audio file to upload.": "The video or audio file to upload.", + "The file to upload. Its name must end with a supported extension, e.g. .pdf or .zip.": "The file to upload. Its name must end with a supported extension, e.g. .pdf or .zip.", + "Only return events for this member. Get the ID from List Members or Get Member by Email.": "Only return events for this member. Get the ID from List Members or Get Member by Email.", + "Only return this kind of event.": "Only return this kind of event.", + "A Ghost NQL filter, e.g. data.created_at:<'2026-01-01 00:00:00'. Quote values that contain spaces or special characters with single quotes.": "A Ghost NQL filter, e.g. data.created_at:<'2026-01-01 00:00:00'. Quote values that contain spaces or special characters with single quotes.", "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.", "Draft": "Draft", "Published": "Published", "Scheduled": "Scheduled", + "Yes": "Yes", + "No": "No", + "Public": "Public", + "Members only": "Members only", + "Paid members only": "Paid members only", + "Newsletter sender address": "Newsletter sender address", + "Support address": "Support address", + "All members": "All members", + "Hidden": "Hidden", + "Monthly": "Monthly", + "Yearly": "Yearly", + "Percentage discount": "Percentage discount", + "Fixed amount discount": "Fixed amount discount", + "Free trial": "Free trial", + "First payment only": "First payment only", + "Several months": "Several months", + "Forever": "Forever", + "Content or feature image": "Content or feature image", + "Profile image (square)": "Profile image (square)", + "Site icon (square)": "Site icon (square)", + "Signup": "Signup", + "Login": "Login", + "Newsletter subscribe or unsubscribe": "Newsletter subscribe or unsubscribe", + "Email delivered": "Email delivered", + "Email opened": "Email opened", + "Email link clicked": "Email link clicked", + "Email failed": "Email failed", + "Comment": "Comment", + "Feedback": "Feedback", + "Paid subscription change": "Paid subscription change", + "Payment": "Payment", + "Email address change": "Email address change", + "Donation": "Donation", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw", "Member Added": "Member Added", "Member Edited": "Member Edited", "Member Deleted": "Member Deleted", diff --git a/packages/pieces/community/ghostcms/src/index.ts b/packages/pieces/community/ghostcms/src/index.ts index 0702a7555e69..67973d2da3d9 100644 --- a/packages/pieces/community/ghostcms/src/index.ts +++ b/packages/pieces/community/ghostcms/src/index.ts @@ -1,17 +1,79 @@ -import { - createPiece, - PieceAuth, - Property, -} from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { createCustomApiCallAction } from '@activepieces/pieces-common'; import { PieceCategory } from '@activepieces/pieces-framework'; +import { ghostAuth } from './lib/auth'; import { createMember } from './lib/actions/create-member'; import { createPost } from './lib/actions/create-post'; import { findMember } from './lib/actions/find-member'; import { findUser } from './lib/actions/find-user'; import { updateMember } from './lib/actions/update-member'; +import { ghostListPosts } from './lib/actions/ai/list-posts'; +import { ghostGetPost } from './lib/actions/ai/get-post'; +import { ghostGetPostBySlug } from './lib/actions/ai/get-post-by-slug'; +import { ghostCreatePost } from './lib/actions/ai/create-post'; +import { ghostUpdatePost } from './lib/actions/ai/update-post'; +import { ghostPublishPost } from './lib/actions/ai/publish-post'; +import { ghostSchedulePost } from './lib/actions/ai/schedule-post'; +import { ghostUnpublishPost } from './lib/actions/ai/unpublish-post'; +import { ghostCopyPost } from './lib/actions/ai/copy-post'; +import { ghostDeletePost } from './lib/actions/ai/delete-post'; +import { ghostListPages } from './lib/actions/ai/list-pages'; +import { ghostGetPage } from './lib/actions/ai/get-page'; +import { ghostGetPageBySlug } from './lib/actions/ai/get-page-by-slug'; +import { ghostCreatePage } from './lib/actions/ai/create-page'; +import { ghostUpdatePage } from './lib/actions/ai/update-page'; +import { ghostCopyPage } from './lib/actions/ai/copy-page'; +import { ghostDeletePage } from './lib/actions/ai/delete-page'; +import { ghostListTags } from './lib/actions/ai/list-tags'; +import { ghostGetTag } from './lib/actions/ai/get-tag'; +import { ghostGetTagBySlug } from './lib/actions/ai/get-tag-by-slug'; +import { ghostCreateTag } from './lib/actions/ai/create-tag'; +import { ghostUpdateTag } from './lib/actions/ai/update-tag'; +import { ghostDeleteTag } from './lib/actions/ai/delete-tag'; +import { ghostListMembers } from './lib/actions/ai/list-members'; +import { ghostGetMember } from './lib/actions/ai/get-member'; +import { ghostGetMemberByEmail } from './lib/actions/ai/get-member-by-email'; +import { ghostCreateMember } from './lib/actions/ai/create-member'; +import { ghostUpdateMember } from './lib/actions/ai/update-member'; +import { ghostDeleteMember } from './lib/actions/ai/delete-member'; +import { ghostAddMemberLabel } from './lib/actions/ai/add-member-label'; +import { ghostRemoveMemberLabel } from './lib/actions/ai/remove-member-label'; +import { ghostSubscribeMemberToNewsletters } from './lib/actions/ai/subscribe-member-to-newsletters'; +import { ghostUnsubscribeMemberFromNewsletters } from './lib/actions/ai/unsubscribe-member-from-newsletters'; +import { ghostListLabels } from './lib/actions/ai/list-labels'; +import { ghostGetLabel } from './lib/actions/ai/get-label'; +import { ghostCreateLabel } from './lib/actions/ai/create-label'; +import { ghostUpdateLabel } from './lib/actions/ai/update-label'; +import { ghostDeleteLabel } from './lib/actions/ai/delete-label'; +import { ghostListNewsletters } from './lib/actions/ai/list-newsletters'; +import { ghostGetNewsletter } from './lib/actions/ai/get-newsletter'; +import { ghostCreateNewsletter } from './lib/actions/ai/create-newsletter'; +import { ghostUpdateNewsletter } from './lib/actions/ai/update-newsletter'; +import { ghostArchiveNewsletter } from './lib/actions/ai/archive-newsletter'; +import { ghostListTiers } from './lib/actions/ai/list-tiers'; +import { ghostGetTier } from './lib/actions/ai/get-tier'; +import { ghostCreateTier } from './lib/actions/ai/create-tier'; +import { ghostUpdateTier } from './lib/actions/ai/update-tier'; +import { ghostArchiveTier } from './lib/actions/ai/archive-tier'; +import { ghostListOffers } from './lib/actions/ai/list-offers'; +import { ghostGetOffer } from './lib/actions/ai/get-offer'; +import { ghostCreateOffer } from './lib/actions/ai/create-offer'; +import { ghostUpdateOffer } from './lib/actions/ai/update-offer'; +import { ghostArchiveOffer } from './lib/actions/ai/archive-offer'; +import { ghostListUsers } from './lib/actions/ai/list-users'; +import { ghostGetUser } from './lib/actions/ai/get-user'; +import { ghostGetUserByEmail } from './lib/actions/ai/get-user-by-email'; +import { ghostUploadImage } from './lib/actions/ai/upload-image'; +import { ghostGetSite } from './lib/actions/ai/get-site'; +import { ghostUploadMedia } from './lib/actions/ai/upload-media'; +import { ghostUploadFile } from './lib/actions/ai/upload-file'; +import { ghostGetSettings } from './lib/actions/ai/get-settings'; +import { ghostListMemberActivity } from './lib/actions/ai/list-member-activity'; +import { ghostGetMemberStats } from './lib/actions/ai/get-member-stats'; import { common } from './lib/common'; +import { ghostClient } from './lib/common/client'; +import { assertGhostUrl } from './lib/common/custom-api-guard'; import { memberAdded } from './lib/triggers/member-added'; import { memberDeleted } from './lib/triggers/member-deleted'; import { memberEdited } from './lib/triggers/member-edited'; @@ -19,38 +81,14 @@ import { pagePublished } from './lib/triggers/page-published'; import { postPublished } from './lib/triggers/post-published'; import { postScheduled } from './lib/triggers/post-scheduled'; -const authMarkdown = ` -To generate an API key, follow the steps below in GhostCMS: -1. Go to Settings -> Advanced -> Integrations. -2. Scroll down to Custom Integrations and click Add custom integration. -3. Enter integration name and click create. -4. Copy the API URL and the Admin API Key into the fields below. -`; - -export const ghostAuth = PieceAuth.CustomAuth({ - description: authMarkdown, - required: true, - props: { - baseUrl: Property.ShortText({ - displayName: 'API URL', - description: - 'The API URL of your application (https://test-publication.ghost.io)', - required: true, - }), - apiKey: PieceAuth.SecretText({ - displayName: 'Admin API Key', - description: 'The admin API key for your application', - required: true, - }), - }, -}); +export { ghostAuth }; export const ghostcms = createPiece({ displayName: 'GhostCMS', description: 'Publishing platform for professional bloggers', auth: ghostAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/ghostcms.png', categories: [PieceCategory.MARKETING], authors: ["kishanprmr","MoShizzle","abuaboud"], @@ -60,15 +98,78 @@ export const ghostcms = createPiece({ createPost, findMember, findUser, + ghostListPosts, + ghostGetPost, + ghostGetPostBySlug, + ghostCreatePost, + ghostUpdatePost, + ghostPublishPost, + ghostSchedulePost, + ghostUnpublishPost, + ghostCopyPost, + ghostDeletePost, + ghostListPages, + ghostGetPage, + ghostGetPageBySlug, + ghostCreatePage, + ghostUpdatePage, + ghostCopyPage, + ghostDeletePage, + ghostListTags, + ghostGetTag, + ghostGetTagBySlug, + ghostCreateTag, + ghostUpdateTag, + ghostDeleteTag, + ghostListMembers, + ghostGetMember, + ghostGetMemberByEmail, + ghostCreateMember, + ghostUpdateMember, + ghostDeleteMember, + ghostAddMemberLabel, + ghostRemoveMemberLabel, + ghostSubscribeMemberToNewsletters, + ghostUnsubscribeMemberFromNewsletters, + ghostListLabels, + ghostGetLabel, + ghostCreateLabel, + ghostUpdateLabel, + ghostDeleteLabel, + ghostListNewsletters, + ghostGetNewsletter, + ghostCreateNewsletter, + ghostUpdateNewsletter, + ghostArchiveNewsletter, + ghostListTiers, + ghostGetTier, + ghostCreateTier, + ghostUpdateTier, + ghostArchiveTier, + ghostListOffers, + ghostGetOffer, + ghostCreateOffer, + ghostUpdateOffer, + ghostArchiveOffer, + ghostListUsers, + ghostGetUser, + ghostGetUserByEmail, + ghostUploadImage, + ghostGetSite, + ghostUploadMedia, + ghostUploadFile, + ghostGetSettings, + ghostListMemberActivity, + ghostGetMemberStats, createCustomApiCallAction({ - baseUrl: (auth) =>auth ? - `${auth.props.baseUrl}/ghost/api/admin` : '', + baseUrl: (auth) => (auth ? ghostClient.adminUrl(auth) : ''), auth: ghostAuth, - authMapping: async (auth) => ({ - Authorization: `Ghost ${common.jwtFromApiKey( - auth.props.apiKey - )}`, - }), + authMapping: async (auth, propsValue) => { + assertGhostUrl({ auth, propsValue }); + return { + Authorization: `Ghost ${common.jwtFromApiKey(auth.props.apiKey)}`, + }; + }, }), ], triggers: [ diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/add-member-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/add-member-label.ts new file mode 100644 index 000000000000..8a2162d173ea --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/add-member-label.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { changeMemberLabel } from '../../common/member-labels'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostAddMemberLabel = createAction({ + auth: ghostAuth, + name: 'ghost_add_member_label', + outputSchema: ghostMemberOutputSchema, + classification: 'WRITE', + displayName: 'Add Member Label', + description: 'Add one label to a member, keeping their other labels.', + audience: 'ai', + aiMetadata: { + description: + 'Adds one existing label to a member without touching their other labels, and returns the updated member. Get the label ID from List Labels or Create Label. Adding a label the member already has is a no-op.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + label_id: ghostProps.id('Label ID', 'The label ID, from List Labels.'), + }, + async run(context) { + return changeMemberLabel({ + auth: context.auth, + memberId: context.propsValue.member_id, + labelId: context.propsValue.label_id, + action: 'addLabel', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-newsletter.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-newsletter.ts new file mode 100644 index 000000000000..b6ccf02c8a78 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-newsletter.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostNewsletterOutputSchema } from '../../output-schemas'; + +export const ghostArchiveNewsletter = createAction({ + auth: ghostAuth, + name: 'ghost_archive_newsletter', + outputSchema: ghostNewsletterOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Archive Newsletter', + description: 'Archive a newsletter so it can no longer be sent or subscribed to.', + audience: 'ai', + aiMetadata: { + description: + 'Archives a newsletter: members can no longer subscribe and posts can no longer be emailed to it. Staff can reactivate it in Ghost Admin. Archiving an archived newsletter is a no-op.', + idempotent: true, + }, + props: { + newsletter_id: ghostProps.id('Newsletter ID', 'The newsletter ID, from List Newsletters.'), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.newsletter_id, 'Newsletter ID'); + return ghostResource.edit(context.auth, 'newsletters', id, { status: 'archived' }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-offer.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-offer.ts new file mode 100644 index 000000000000..517ccac1d3cf --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-offer.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostOfferOutputSchema } from '../../output-schemas'; + +export const ghostArchiveOffer = createAction({ + auth: ghostAuth, + name: 'ghost_archive_offer', + outputSchema: ghostOfferOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Archive Offer', + description: 'Archive an offer so it can no longer be redeemed.', + audience: 'ai', + aiMetadata: { + description: + 'Archives an offer: its link and code stop working for new redemptions, while members who already redeemed it keep their discount. Staff can reactivate it in Ghost Admin. Archiving an archived offer is a no-op.', + idempotent: true, + }, + props: { + offer_id: ghostProps.id('Offer ID', 'The offer ID, from List Offers.'), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.offer_id, 'Offer ID'); + return ghostResource.edit(context.auth, 'offers', id, { status: 'archived' }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-tier.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-tier.ts new file mode 100644 index 000000000000..39ad747778df --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/archive-tier.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { TIER_QUERY } from '../../common/tier-props'; +import { ghostTierOutputSchema } from '../../output-schemas'; + +export const ghostArchiveTier = createAction({ + auth: ghostAuth, + name: 'ghost_archive_tier', + outputSchema: ghostTierOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Archive Tier', + description: 'Archive a paid tier so new members can no longer buy it.', + audience: 'ai', + aiMetadata: { + description: + 'Archives a paid membership tier: it disappears from the pricing page and cannot be bought, while existing subscribers keep it. Staff can reactivate it in Ghost Admin. Archiving an archived tier is a no-op.', + idempotent: true, + }, + props: { + tier_id: ghostProps.id('Tier ID', 'The tier ID, from List Tiers.'), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.tier_id, 'Tier ID'); + return ghostResource.edit(context.auth, 'tiers', id, { active: false }, TIER_QUERY); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-page.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-page.ts new file mode 100644 index 000000000000..a9107b331bd4 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-page.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPageOutputSchema } from '../../output-schemas'; + +export const ghostCopyPage = createAction({ + auth: ghostAuth, + name: 'ghost_copy_page', + outputSchema: ghostPageOutputSchema, + classification: 'WRITE', + displayName: 'Copy Page', + description: 'Duplicate a page as a new draft.', + audience: 'ai', + aiMetadata: { + description: + 'Duplicates a static page into a new draft with the same content and returns the copy. Each call creates another copy.', + idempotent: false, + }, + props: { + page_id: ghostProps.id('Page ID', 'The ID of the page to copy, from List Pages.'), + }, + async run(context) { + return ghostContent.copy(context.auth, 'pages', ghostCommon.id(context.propsValue.page_id, 'Page ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-post.ts new file mode 100644 index 000000000000..719b59b6af07 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/copy-post.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostCopyPost = createAction({ + auth: ghostAuth, + name: 'ghost_copy_post', + outputSchema: ghostPostOutputSchema, + classification: 'WRITE', + displayName: 'Copy Post', + description: 'Duplicate a post as a new draft.', + audience: 'ai', + aiMetadata: { + description: + 'Duplicates a post into a new draft with the same content, tags and authors and returns the copy. Each call creates another copy.', + idempotent: false, + }, + props: { + post_id: ghostProps.id('Post ID', 'The ID of the post to copy, from List Posts.'), + }, + async run(context) { + return ghostContent.copy(context.auth, 'posts', ghostCommon.id(context.propsValue.post_id, 'Post ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-label.ts new file mode 100644 index 000000000000..7cb7cffeaddd --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-label.ts @@ -0,0 +1,29 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostResource } from '../../common/resources'; +import { ghostCreateLabelOutputSchema } from '../../output-schemas'; + +export const ghostCreateLabel = createAction({ + auth: ghostAuth, + name: 'ghost_create_label', + outputSchema: ghostCreateLabelOutputSchema, + classification: 'WRITE', + displayName: 'Create Label', + description: 'Create a member label.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a member label used to segment members. Ghost rejects a duplicate name, so check List Labels first. Returns the label ID for Add Member Label.', + idempotent: false, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'The label name.', + required: true, + }), + }, + async run(context) { + return ghostResource.create(context.auth, 'labels', ghostResource.pick(context.propsValue, ['name'])); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-member.ts new file mode 100644 index 000000000000..2909619e985e --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-member.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostCreateMember = createAction({ + auth: ghostAuth, + name: 'ghost_create_member', + outputSchema: ghostMemberOutputSchema, + classification: 'WRITE', + displayName: 'Create Member', + description: 'Add a new member to the publication.', + audience: 'ai', + aiMetadata: { + description: + 'Adds a free member by email with optional name, note, labels (by name, created if missing) and newsletter IDs. Without Newsletter IDs Ghost subscribes the member to the newsletters that auto-subscribe new signups. Ghost rejects an email that is already a member, so use Get Member by Email first.', + idempotent: false, + }, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'The member email address.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Name', + description: 'The member full name.', + required: false, + }), + note: Property.LongText({ + displayName: 'Note', + description: 'A private note about the member, visible only to staff.', + required: false, + }), + labels: Property.Array({ + displayName: 'Labels', + description: 'Label names to apply. Missing labels are created.', + required: false, + }), + newsletter_ids: ghostProps.newsletterIds( + 'Newsletter IDs', + 'The newsletters to subscribe the member to, from List Newsletters.', + false + ), + }, + async run(context) { + const { email, name, note, labels, newsletter_ids } = context.propsValue; + const body = ghostResource.pick({ email, name, note }, ['email', 'name', 'note']); + const labelNames = ghostCommon.stringList(labels); + if (labelNames) { + body['labels'] = labelNames.map((label) => ({ name: label })); + } + const newsletters = ghostCommon.stringList(newsletter_ids); + if (newsletters) { + body['newsletters'] = newsletters.map((id) => ({ id })); + } + return ghostResource.create(context.auth, 'members', body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-newsletter.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-newsletter.ts new file mode 100644 index 000000000000..a41ab32a0ef5 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-newsletter.ts @@ -0,0 +1,40 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostCommon } from '../../common/client'; +import { NEWSLETTER_FIELDS, newsletterProps } from '../../common/newsletter-props'; +import { ghostResource } from '../../common/resources'; +import { ghostNewsletterOutputSchema } from '../../output-schemas'; + +export const ghostCreateNewsletter = createAction({ + auth: ghostAuth, + name: 'ghost_create_newsletter', + outputSchema: ghostNewsletterOutputSchema, + classification: 'WRITE', + displayName: 'Create Newsletter', + description: 'Create a new newsletter.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a newsletter members can subscribe to; it changes the public signup options of the site. Opt In Existing Members subscribes every current member at once. The sender email is not set here because Ghost must verify it by email first. Each call creates a new newsletter.', + idempotent: false, + }, + props: { + ...newsletterProps('create'), + opt_in_existing: Property.Checkbox({ + displayName: 'Opt In Existing Members', + description: 'Subscribe every existing member who is subscribed to at least one newsletter.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const body = ghostResource.pick(context.propsValue, NEWSLETTER_FIELDS); + const subscribeOnSignup = ghostCommon.triState(context.propsValue.subscribe_on_signup); + if (subscribeOnSignup !== undefined) { + body['subscribe_on_signup'] = subscribeOnSignup; + } + return ghostResource.create(context.auth, 'newsletters', body, { + opt_in_existing: context.propsValue.opt_in_existing === true ? 'true' : undefined, + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-offer.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-offer.ts new file mode 100644 index 000000000000..ea92f36ce9d9 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-offer.ts @@ -0,0 +1,133 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostCreateOfferOutputSchema } from '../../output-schemas'; + +export const ghostCreateOffer = createAction({ + auth: ghostAuth, + name: 'ghost_create_offer', + outputSchema: ghostCreateOfferOutputSchema, + classification: 'WRITE', + displayName: 'Create Offer', + description: 'Create a discount or free-trial offer for a paid tier.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a public discount or free-trial offer on a paid tier; anyone with the code or offer link can redeem it, so confirm the terms before calling. Percent and fixed offers need Duration, and a fixed offer needs Currency. Ghost rejects a code already in use.', + idempotent: false, + }, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'The internal offer name.', + required: true, + }), + code: Property.ShortText({ + displayName: 'Code', + description: 'The code used in the offer URL, e.g. black-friday.', + required: true, + }), + tier_id: Property.ShortText({ + displayName: 'Tier ID', + description: 'The paid tier the offer applies to, from List Tiers.', + required: true, + }), + cadence: Property.StaticDropdown({ + displayName: 'Cadence', + description: 'Whether the offer applies to the monthly or the yearly price.', + required: true, + options: { + options: [ + { label: 'Monthly', value: 'month' }, + { label: 'Yearly', value: 'year' }, + ], + }, + }), + type: Property.StaticDropdown({ + displayName: 'Type', + description: 'Percentage discount, fixed amount off, or a free trial.', + required: true, + options: { + options: [ + { label: 'Percentage discount', value: 'percent' }, + { label: 'Fixed amount discount', value: 'fixed' }, + { label: 'Free trial', value: 'trial' }, + ], + }, + }), + amount: Property.Number({ + displayName: 'Amount', + description: + 'Percent: 1 to 100. Fixed: the amount off in the smallest currency unit (500 = 5.00). Trial: the number of free days.', + required: true, + }), + duration: Property.StaticDropdown({ + displayName: 'Duration', + description: 'How long a percent or fixed discount lasts. Ignored for trials.', + required: false, + options: { + options: [ + { label: 'First payment only', value: 'once' }, + { label: 'Several months', value: 'repeating' }, + { label: 'Forever', value: 'forever' }, + ], + }, + }), + duration_in_months: Property.Number({ + displayName: 'Duration in Months', + description: 'Required when Duration is Several months.', + required: false, + }), + currency: Property.ShortText({ + displayName: 'Currency', + description: 'The three-letter currency code of a fixed discount, e.g. usd.', + required: false, + }), + display_title: Property.ShortText({ + displayName: 'Display Title', + description: 'The headline shown to visitors on the offer page.', + required: false, + }), + display_description: Property.LongText({ + displayName: 'Display Description', + description: 'The text shown to visitors on the offer page.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = ghostResource.pick(props, ['name', 'code', 'cadence', 'type', 'display_title', 'display_description']); + const amount = Number(props.amount); + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error('Amount must be a whole number above 0.'); + } + body['amount'] = amount; + body['tier'] = { id: props.tier_id.trim() }; + if (props.type === 'trial') { + body['duration'] = 'trial'; + } else { + if (!ghostCommon.hasText(props.duration)) { + throw new Error('Duration is required for percent and fixed offers.'); + } + body['duration'] = props.duration; + if (props.duration === 'repeating') { + const months = Number(props.duration_in_months); + if (!Number.isInteger(months) || months <= 0) { + throw new Error('Duration in Months is required when Duration is Several months.'); + } + body['duration_in_months'] = months; + } + if (props.type === 'percent' && amount > 100) { + throw new Error('A percentage discount cannot exceed 100.'); + } + if (props.type === 'fixed') { + if (!ghostCommon.hasText(props.currency)) { + throw new Error('Currency is required for a fixed discount.'); + } + body['currency'] = props.currency.trim().toLowerCase(); + } + } + return ghostResource.create(context.auth, 'offers', body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-page.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-page.ts new file mode 100644 index 000000000000..0adb49029f7b --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-page.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostCommon } from '../../common/client'; +import { contentProps, ghostContent } from '../../common/content'; +import { ghostPageOutputSchema } from '../../output-schemas'; + +export const ghostCreatePage = createAction({ + auth: ghostAuth, + name: 'ghost_create_page', + outputSchema: ghostPageOutputSchema, + classification: 'WRITE', + displayName: 'Create Page', + description: 'Create a static page as a draft, published or scheduled.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a static page (such as About or Contact, not a blog post) from a title and optional HTML. It is saved as a draft unless Status says otherwise; pages are never emailed. Each call creates a new page.', + idempotent: false, + }, + props: { + ...contentProps({ label: 'page', mode: 'create' }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Draft (default), published now, or scheduled for Published At.', + required: false, + defaultValue: 'draft', + options: { + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Scheduled', value: 'scheduled' }, + ], + }, + }), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'Required when Status is Scheduled and must be in the future.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = ghostContent.body(props); + const status = props.status ?? 'draft'; + body['status'] = status; + if (status === 'scheduled') { + body['published_at'] = ghostCommon.futureDate(props.published_at, 'Published At'); + } else if (ghostCommon.hasText(props.published_at)) { + body['published_at'] = props.published_at; + } + return ghostContent.create(context.auth, 'pages', body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-post.ts new file mode 100644 index 000000000000..8a8fe42d9a6b --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-post.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostCommon } from '../../common/client'; +import { contentProps, ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostCreatePost = createAction({ + auth: ghostAuth, + name: 'ghost_create_post', + outputSchema: ghostPostOutputSchema, + classification: 'WRITE', + displayName: 'Create Post', + description: 'Create a post as a draft, published or scheduled.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a post from a title and optional HTML, tags (by name, created if missing) and authors. It is saved as a draft unless Status says otherwise; this never emails subscribers, so use Publish Post with a newsletter to send it. Each call creates a new post.', + idempotent: false, + }, + props: { + ...contentProps({ label: 'post', mode: 'create' }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Draft (default), published now, or scheduled for Published At.', + required: false, + defaultValue: 'draft', + options: { + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Scheduled', value: 'scheduled' }, + ], + }, + }), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'Required when Status is Scheduled and must be in the future. Optional backdate for Published.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const body = ghostContent.body(props); + const status = props.status ?? 'draft'; + body['status'] = status; + if (status === 'scheduled') { + body['published_at'] = ghostCommon.futureDate(props.published_at, 'Published At'); + } else if (ghostCommon.hasText(props.published_at)) { + body['published_at'] = props.published_at; + } + return ghostContent.create(context.auth, 'posts', body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tag.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tag.ts new file mode 100644 index 000000000000..7cded6295185 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tag.ts @@ -0,0 +1,24 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostResource } from '../../common/resources'; +import { TAG_FIELDS, tagProps } from '../../common/tag-props'; +import { ghostCreateTagOutputSchema } from '../../output-schemas'; + +export const ghostCreateTag = createAction({ + auth: ghostAuth, + name: 'ghost_create_tag', + outputSchema: ghostCreateTagOutputSchema, + classification: 'WRITE', + displayName: 'Create Tag', + description: 'Create a post tag.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a post tag. Not needed before tagging a post, because Create Post and Update Post create missing tags by name. Each call creates a new tag, so check List Tags first to avoid duplicates.', + idempotent: false, + }, + props: tagProps('create'), + async run(context) { + return ghostResource.create(context.auth, 'tags', ghostResource.pick(context.propsValue, TAG_FIELDS)); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tier.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tier.ts new file mode 100644 index 000000000000..0736a84c84ee --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/create-tier.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostResource } from '../../common/resources'; +import { tierBody } from '../../common/tier-body'; +import { TIER_QUERY, tierProps } from '../../common/tier-props'; +import { ghostTierOutputSchema } from '../../output-schemas'; + +export const ghostCreateTier = createAction({ + auth: ghostAuth, + name: 'ghost_create_tier', + outputSchema: ghostTierOutputSchema, + classification: 'WRITE', + displayName: 'Create Tier', + description: 'Create a paid membership tier.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a paid membership tier with prices in the smallest currency unit; a public tier appears on the site pricing page right away, so confirm pricing before calling. Selling it needs Stripe connected in Ghost. Each call creates a new tier.', + idempotent: false, + }, + props: tierProps('create'), + async run(context) { + const body = tierBody(context.propsValue); + if ((body['monthly_price'] !== undefined || body['yearly_price'] !== undefined) && !body['currency']) { + throw new Error('Currency is required when setting a price.'); + } + return ghostResource.create(context.auth, 'tiers', body, TIER_QUERY); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-label.ts new file mode 100644 index 000000000000..ba7ef8471a79 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-label.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostDeleteLabelOutputSchema } from '../../output-schemas'; + +export const ghostDeleteLabel = createAction({ + auth: ghostAuth, + name: 'ghost_delete_label', + outputSchema: ghostDeleteLabelOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Label', + description: 'Permanently delete a member label.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a member label and removes it from every member; the members stay. It cannot be restored, and a retry fails with not found.', + idempotent: false, + }, + props: { + label_id: ghostProps.id('Label ID', 'The label ID, from List Labels.'), + }, + async run(context) { + const label_id = context.propsValue.label_id.trim(); + await ghostResource.remove(context.auth, 'labels', ghostCommon.id(label_id, 'Label ID')); + return { success: true, label_id }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-member.ts new file mode 100644 index 000000000000..448eec67c05d --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-member.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostDeleteMemberOutputSchema } from '../../output-schemas'; + +export const ghostDeleteMember = createAction({ + auth: ghostAuth, + name: 'ghost_delete_member', + outputSchema: ghostDeleteMemberOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Member', + description: 'Permanently delete a member.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a member and their history; it cannot be restored. A paid member keeps being billed by Stripe unless Cancel Stripe Subscriptions is on. A retry fails with not found.', + idempotent: false, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + cancel_stripe_subscriptions: Property.Checkbox({ + displayName: 'Cancel Stripe Subscriptions', + description: 'Also cancel the member paid Stripe subscriptions. Off by default.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const member_id = context.propsValue.member_id.trim(); + const cancel = context.propsValue.cancel_stripe_subscriptions === true; + await ghostResource.remove(context.auth, 'members', ghostCommon.id(member_id, 'Member ID'), { + cancel: cancel ? 'true' : undefined, + }); + return { success: true, member_id, stripe_subscriptions_cancelled: cancel }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-page.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-page.ts new file mode 100644 index 000000000000..1b51dcdb4319 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-page.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostDeletePageOutputSchema } from '../../output-schemas'; + +export const ghostDeletePage = createAction({ + auth: ghostAuth, + name: 'ghost_delete_page', + outputSchema: ghostDeletePageOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Page', + description: 'Permanently delete a page.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a static page by ID; it cannot be restored. Prefer Update Page with Status Draft to take it off the site. A retry fails with not found.', + idempotent: false, + }, + props: { + page_id: ghostProps.id('Page ID', 'The page ID, from List Pages.'), + }, + async run(context) { + const page_id = context.propsValue.page_id.trim(); + await ghostContent.remove(context.auth, 'pages', ghostCommon.id(page_id, 'Page ID')); + return { success: true, page_id }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-post.ts new file mode 100644 index 000000000000..85e76e472c04 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-post.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostDeletePostOutputSchema } from '../../output-schemas'; + +export const ghostDeletePost = createAction({ + auth: ghostAuth, + name: 'ghost_delete_post', + outputSchema: ghostDeletePostOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Post', + description: 'Permanently delete a post.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a post by ID; it cannot be restored. Prefer Unpublish Post to take a post off the site. A retry fails with not found.', + idempotent: false, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + }, + async run(context) { + const post_id = context.propsValue.post_id.trim(); + await ghostContent.remove(context.auth, 'posts', ghostCommon.id(post_id, 'Post ID')); + return { success: true, post_id }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-tag.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-tag.ts new file mode 100644 index 000000000000..b012a33a32d8 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/delete-tag.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostDeleteTagOutputSchema } from '../../output-schemas'; + +export const ghostDeleteTag = createAction({ + auth: ghostAuth, + name: 'ghost_delete_tag', + outputSchema: ghostDeleteTagOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Tag', + description: 'Permanently delete a tag.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a tag and removes it from every post that has it; the posts themselves stay. It cannot be restored, and a retry fails with not found.', + idempotent: false, + }, + props: { + tag_id: ghostProps.id('Tag ID', 'The tag ID, from List Tags.'), + }, + async run(context) { + const tag_id = context.propsValue.tag_id.trim(); + await ghostResource.remove(context.auth, 'tags', ghostCommon.id(tag_id, 'Tag ID')); + return { success: true, tag_id }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-label.ts new file mode 100644 index 000000000000..a1676b1e9fea --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-label.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostLabelOutputSchema } from '../../output-schemas'; + +export const ghostGetLabel = createAction({ + auth: ghostAuth, + name: 'ghost_get_label', + outputSchema: ghostLabelOutputSchema, + classification: 'READ', + displayName: 'Get Label', + description: 'Get a member label by ID.', + audience: 'ai', + aiMetadata: { + description: 'Returns one member label by ID with its name, slug and member count.', + idempotent: true, + }, + props: { + label_id: ghostProps.id('Label ID', 'The label ID, from List Labels.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'labels', ghostCommon.id(context.propsValue.label_id, 'Label ID'), { + include: 'count.members', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-by-email.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-by-email.ts new file mode 100644 index 000000000000..678ed8c27ba1 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-by-email.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient, ghostCommon } from '../../common/client'; +import { ghostGetMemberByEmailOutputSchema } from '../../output-schemas'; + +export const ghostGetMemberByEmail = createAction({ + auth: ghostAuth, + name: 'ghost_get_member_by_email', + outputSchema: ghostGetMemberByEmailOutputSchema, + classification: 'READ', + displayName: 'Get Member by Email', + description: 'Find a member by exact email address.', + audience: 'ai', + aiMetadata: { + description: + 'Looks up a member by exact email address and returns found true with the member, or found false when no member has that email. Use it to check whether someone is already a member before Create Member.', + idempotent: true, + }, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'The member email address.', + required: true, + }), + }, + async run(context) { + const email = context.propsValue.email.trim(); + if (!email) { + throw new Error('Email is required.'); + } + const response = await ghostClient.request<{ members?: Record[] }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/members', + query: { filter: `email:${ghostCommon.nqlString(email)}`, limit: 1 }, + }); + const member = response.members?.[0] ?? null; + return { found: member !== null, member }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-stats.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-stats.ts new file mode 100644 index 000000000000..8aa28728db41 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member-stats.ts @@ -0,0 +1,53 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import { ghostMemberStatsOutputSchema } from '../../output-schemas'; + +type MemberCount = { + date: string; + paid: number; + free: number; + comped: number; + gift?: number; +}; + +export const ghostGetMemberStats = createAction({ + auth: ghostAuth, + name: 'ghost_get_member_stats', + outputSchema: ghostMemberStatsOutputSchema, + classification: 'READ', + displayName: 'Get Member Stats', + description: 'Get the current member totals (free, paid, comped) and the daily history.', + audience: 'ai', + aiMetadata: { + description: + 'Returns how many members the publication has right now, split into free, paid, comped and gift, plus one row per day with the counts on that day. Use it to answer growth questions; use List Members to see who the members are.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await ghostClient.request<{ total?: number; data?: MemberCount[] }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/members/stats/count', + }); + const history = (response.data ?? []).map((day) => ({ + date: day.date, + free: day.free ?? 0, + paid: day.paid ?? 0, + comped: day.comped ?? 0, + gift: day.gift ?? 0, + })); + const latest = history[history.length - 1]; + return { + total: response.total ?? 0, + free: latest?.free ?? 0, + paid: latest?.paid ?? 0, + comped: latest?.comped ?? 0, + gift: latest?.gift ?? 0, + as_of: latest?.date ?? null, + history, + }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member.ts new file mode 100644 index 000000000000..5c6070696228 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-member.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostGetMember = createAction({ + auth: ghostAuth, + name: 'ghost_get_member', + outputSchema: ghostMemberOutputSchema, + classification: 'READ', + displayName: 'Get Member', + description: 'Get a member by ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one member by ID with labels, newsletter subscriptions, tiers and paid status. Use Get Member by Email when only the email is known.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'members', ghostCommon.id(context.propsValue.member_id, 'Member ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-newsletter.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-newsletter.ts new file mode 100644 index 000000000000..8d1161dc7c4e --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-newsletter.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostNewsletterOutputSchema } from '../../output-schemas'; + +export const ghostGetNewsletter = createAction({ + auth: ghostAuth, + name: 'ghost_get_newsletter', + outputSchema: ghostNewsletterOutputSchema, + classification: 'READ', + displayName: 'Get Newsletter', + description: 'Get a newsletter by ID.', + audience: 'ai', + aiMetadata: { + description: 'Returns one newsletter by ID with its slug, status, sender and design settings.', + idempotent: true, + }, + props: { + newsletter_id: ghostProps.id('Newsletter ID', 'The newsletter ID, from List Newsletters.'), + }, + async run(context) { + return ghostResource.get( + context.auth, + 'newsletters', + ghostCommon.id(context.propsValue.newsletter_id, 'Newsletter ID') + ); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-offer.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-offer.ts new file mode 100644 index 000000000000..e50729689bfd --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-offer.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostOfferOutputSchema } from '../../output-schemas'; + +export const ghostGetOffer = createAction({ + auth: ghostAuth, + name: 'ghost_get_offer', + outputSchema: ghostOfferOutputSchema, + classification: 'READ', + displayName: 'Get Offer', + description: 'Get an offer by ID.', + audience: 'ai', + aiMetadata: { + description: 'Returns one offer by ID with its code, discount type and amount, duration, cadence, tier and redemption count.', + idempotent: true, + }, + props: { + offer_id: ghostProps.id('Offer ID', 'The offer ID, from List Offers.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'offers', ghostCommon.id(context.propsValue.offer_id, 'Offer ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page-by-slug.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page-by-slug.ts new file mode 100644 index 000000000000..8bcbaa3db83c --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page-by-slug.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPageOutputSchema } from '../../output-schemas'; + +export const ghostGetPageBySlug = createAction({ + auth: ghostAuth, + name: 'ghost_get_page_by_slug', + outputSchema: ghostPageOutputSchema, + classification: 'READ', + displayName: 'Get Page by Slug', + description: 'Get a page by its URL slug.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one static page by its slug, the last part of its URL, with its HTML body. Use Get Page when the ID is known.', + idempotent: true, + }, + props: { + slug: ghostProps.slug('The page slug, e.g. "about" for https://example.com/about/.'), + }, + async run(context) { + return ghostContent.get(context.auth, 'pages', `slug/${ghostCommon.id(context.propsValue.slug, 'Slug')}`); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page.ts new file mode 100644 index 000000000000..d9012df403df --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-page.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPageOutputSchema } from '../../output-schemas'; + +export const ghostGetPage = createAction({ + auth: ghostAuth, + name: 'ghost_get_page', + outputSchema: ghostPageOutputSchema, + classification: 'READ', + displayName: 'Get Page', + description: 'Get a page by ID, including its HTML content.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one static page by ID with its HTML body, tags, authors, status and URL. Use Get Page by Slug when only the slug is known.', + idempotent: true, + }, + props: { + page_id: ghostProps.id('Page ID', 'The page ID, from List Pages.'), + }, + async run(context) { + return ghostContent.get(context.auth, 'pages', ghostCommon.id(context.propsValue.page_id, 'Page ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post-by-slug.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post-by-slug.ts new file mode 100644 index 000000000000..fd4c9fa05b28 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post-by-slug.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostGetPostBySlug = createAction({ + auth: ghostAuth, + name: 'ghost_get_post_by_slug', + outputSchema: ghostPostOutputSchema, + classification: 'READ', + displayName: 'Get Post by Slug', + description: 'Get a post by its URL slug.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one post by its slug, the last part of the post URL, with its HTML body, tags and authors. Use Get Post when the ID is known.', + idempotent: true, + }, + props: { + slug: ghostProps.slug('The post slug, e.g. "welcome" for https://example.com/welcome/.'), + }, + async run(context) { + return ghostContent.get(context.auth, 'posts', `slug/${ghostCommon.id(context.propsValue.slug, 'Slug')}`); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post.ts new file mode 100644 index 000000000000..fbc5e70ecc33 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-post.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostGetPost = createAction({ + auth: ghostAuth, + name: 'ghost_get_post', + outputSchema: ghostPostOutputSchema, + classification: 'READ', + displayName: 'Get Post', + description: 'Get a post by ID, including its HTML content.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one post by ID with its HTML body, tags, authors, status and URL. Use Get Post by Slug when only the slug is known, or List Posts to find the ID.', + idempotent: true, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + }, + async run(context) { + return ghostContent.get(context.auth, 'posts', ghostCommon.id(context.propsValue.post_id, 'Post ID')); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-settings.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-settings.ts new file mode 100644 index 000000000000..efe91cad446a --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-settings.ts @@ -0,0 +1,97 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import { ghostSettingsOutputSchema } from '../../output-schemas'; + +const PUBLIC_SETTING_KEYS = [ + 'title', + 'description', + 'logo', + 'cover_image', + 'icon', + 'accent_color', + 'locale', + 'timezone', + 'meta_title', + 'meta_description', + 'og_image', + 'og_title', + 'og_description', + 'twitter_image', + 'twitter_title', + 'twitter_description', + 'facebook', + 'twitter', + 'navigation', + 'secondary_navigation', + 'active_theme', + 'is_private', + 'default_content_visibility', + 'members_enabled', + 'members_invite_only', + 'members_signup_access', + 'members_support_address', + 'paid_members_enabled', + 'portal_name', + 'portal_plans', + 'portal_default_plan', + 'comments_enabled', + 'recommendations_enabled', + 'email_track_opens', + 'email_track_clicks', + 'donations_enabled', + 'donations_currency', + 'donations_suggested_amount', + 'editor_default_email_recipients', + 'default_email_address', + 'support_email_address', +] as const; + +const parseValue = (value: unknown): unknown => { + if (typeof value !== 'string') { + return value ?? null; + } + const text = value.trim(); + if (text.startsWith('[') || text.startsWith('{')) { + try { + return JSON.parse(text); + } catch { + return value; + } + } + return value; +}; + +export const ghostGetSettings = createAction({ + auth: ghostAuth, + name: 'ghost_get_settings', + outputSchema: ghostSettingsOutputSchema, + classification: 'READ', + displayName: 'Get Settings', + description: 'Get the publication settings: branding, SEO, navigation, timezone and membership options.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the publication settings an editor needs: title, description, branding images, accent color, locale and timezone, SEO and social meta, navigation menus, active theme, and membership, portal, comments and email-tracking options. Secrets such as Stripe or Mailgun keys and the site password are never returned. Use Get Site for the Ghost version and URL.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await ghostClient.request<{ + settings?: { key: string; value: unknown }[]; + }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/settings', + }); + const byKey = new Map((response.settings ?? []).map((setting) => [setting.key, setting.value])); + const result: Record = {}; + for (const key of PUBLIC_SETTING_KEYS) { + if (byKey.has(key)) { + result[key] = parseValue(byKey.get(key)); + } + } + return result; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-site.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-site.ts new file mode 100644 index 000000000000..7cdd89df149f --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-site.ts @@ -0,0 +1,29 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import { ghostSiteOutputSchema } from '../../output-schemas'; + +export const ghostGetSite = createAction({ + auth: ghostAuth, + name: 'ghost_get_site', + outputSchema: ghostSiteOutputSchema, + classification: 'READ', + displayName: 'Get Site', + description: 'Get the site title, URL and Ghost version.', + audience: 'ai', + aiMetadata: { + description: + 'Returns basic site information: title, description, public URL, logo, icon, accent color and Ghost version. Use it to confirm which publication the connection points at.', + idempotent: true, + }, + props: {}, + async run(context) { + const response = await ghostClient.request<{ site: Record }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/site', + }); + return response.site; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag-by-slug.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag-by-slug.ts new file mode 100644 index 000000000000..d4eef683d0ca --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag-by-slug.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostTagOutputSchema } from '../../output-schemas'; + +export const ghostGetTagBySlug = createAction({ + auth: ghostAuth, + name: 'ghost_get_tag_by_slug', + outputSchema: ghostTagOutputSchema, + classification: 'READ', + displayName: 'Get Tag by Slug', + description: 'Get a tag by its slug.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one tag by its slug, e.g. "news" from /tag/news/, with its post count. Use Get Tag when the ID is known.', + idempotent: true, + }, + props: { + slug: ghostProps.slug('The tag slug, e.g. "news".'), + }, + async run(context) { + return ghostResource.get(context.auth, 'tags', `slug/${ghostCommon.id(context.propsValue.slug, 'Slug')}`, { + include: 'count.posts', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag.ts new file mode 100644 index 000000000000..6ef1d0660251 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tag.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostTagOutputSchema } from '../../output-schemas'; + +export const ghostGetTag = createAction({ + auth: ghostAuth, + name: 'ghost_get_tag', + outputSchema: ghostTagOutputSchema, + classification: 'READ', + displayName: 'Get Tag', + description: 'Get a tag by ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one tag by ID with its description, visibility and post count. Use Get Tag by Slug when only the slug is known.', + idempotent: true, + }, + props: { + tag_id: ghostProps.id('Tag ID', 'The tag ID, from List Tags.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'tags', ghostCommon.id(context.propsValue.tag_id, 'Tag ID'), { + include: 'count.posts', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tier.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tier.ts new file mode 100644 index 000000000000..00dfc8f526a7 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-tier.ts @@ -0,0 +1,27 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { TIER_QUERY } from '../../common/tier-props'; +import { ghostTierOutputSchema } from '../../output-schemas'; + +export const ghostGetTier = createAction({ + auth: ghostAuth, + name: 'ghost_get_tier', + outputSchema: ghostTierOutputSchema, + classification: 'READ', + displayName: 'Get Tier', + description: 'Get a membership tier by ID.', + audience: 'ai', + aiMetadata: { + description: 'Returns one membership tier by ID with its prices, currency, benefits, visibility and active state.', + idempotent: true, + }, + props: { + tier_id: ghostProps.id('Tier ID', 'The tier ID, from List Tiers.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'tiers', ghostCommon.id(context.propsValue.tier_id, 'Tier ID'), TIER_QUERY); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user-by-email.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user-by-email.ts new file mode 100644 index 000000000000..ece1d141c97f --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user-by-email.ts @@ -0,0 +1,44 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { GhostApiError } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostGetUserByEmailOutputSchema } from '../../output-schemas'; + +export const ghostGetUserByEmail = createAction({ + auth: ghostAuth, + name: 'ghost_get_user_by_email', + outputSchema: ghostGetUserByEmailOutputSchema, + classification: 'READ', + displayName: 'Get User by Email', + description: 'Find a staff user by exact email address.', + audience: 'ai', + aiMetadata: { + description: + 'Looks up a staff user (not a member) by exact email and returns found true with the user, or found false. Use Get Member by Email for members.', + idempotent: true, + }, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'The staff user email address.', + required: true, + }), + }, + async run(context) { + const email = context.propsValue.email.trim(); + if (!email) { + throw new Error('Email is required.'); + } + try { + const user = await ghostResource.get(context.auth, 'users', `email/${encodeURIComponent(email)}`, { + include: 'roles,count.posts', + }); + return { found: true, user }; + } catch (error) { + if (error instanceof GhostApiError && error.status === 404) { + return { found: false, user: null }; + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user.ts new file mode 100644 index 000000000000..3c26f5376cc3 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/get-user.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostUserOutputSchema } from '../../output-schemas'; + +export const ghostGetUser = createAction({ + auth: ghostAuth, + name: 'ghost_get_user', + outputSchema: ghostUserOutputSchema, + classification: 'READ', + displayName: 'Get User', + description: 'Get a staff user by ID.', + audience: 'ai', + aiMetadata: { + description: 'Returns one staff user by ID with their name, email, slug, bio, roles and post count.', + idempotent: true, + }, + props: { + user_id: ghostProps.id('User ID', 'The staff user ID, from List Users.'), + }, + async run(context) { + return ghostResource.get(context.auth, 'users', ghostCommon.id(context.propsValue.user_id, 'User ID'), { + include: 'roles,count.posts', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-labels.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-labels.ts new file mode 100644 index 000000000000..659780842441 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-labels.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostListLabelsOutputSchema } from '../../output-schemas'; + +export const ghostListLabels = createAction({ + auth: ghostAuth, + name: 'ghost_list_labels', + outputSchema: ghostListLabelsOutputSchema, + classification: 'SEARCH', + displayName: 'List Labels', + description: 'List member labels.', + audience: 'ai', + aiMetadata: { + description: + 'Lists member labels (not post tags) with member counts and pagination totals. Use it to find the label ID that Add Member Label and Remove Member Label need.', + idempotent: true, + }, + props: { + filter: ghostProps.filter("name:~'vip'"), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('name asc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'labels', { + ...ghostCommon.listQuery(context.propsValue), + include: 'count.members', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-member-activity.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-member-activity.ts new file mode 100644 index 000000000000..50f578a6793e --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-member-activity.ts @@ -0,0 +1,101 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostClient, ghostCommon } from '../../common/client'; +import { ghostListMemberActivityOutputSchema } from '../../output-schemas'; + +type MemberEvent = { + type: string; + data: Record & { + id?: string; + member_id?: string; + created_at?: string; + member?: { id?: string; name?: string | null; email?: string }; + }; +}; + +export const ghostListMemberActivity = createAction({ + auth: ghostAuth, + name: 'ghost_list_member_activity', + outputSchema: ghostListMemberActivityOutputSchema, + classification: 'SEARCH', + displayName: 'List Member Activity', + description: 'List member activity events such as signups, logins, newsletter changes, email opens and payments.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the newest member activity events, newest first: signups, logins, newsletter subscribe/unsubscribe, email deliveries, opens and clicks, comments, feedback, donations, email address changes and paid subscription changes. Give a Member ID for one member\'s timeline, an Event Type to narrow it, or an NQL filter for anything else. Page back by filtering on data.created_at older than the last event returned.', + idempotent: true, + }, + props: { + memberId: Property.ShortText({ + displayName: 'Member ID', + description: 'Only return events for this member. Get the ID from List Members or Get Member by Email.', + required: false, + }), + eventType: Property.StaticDropdown({ + displayName: 'Event Type', + description: 'Only return this kind of event.', + required: false, + options: { + options: [ + { label: 'Signup', value: 'signup_event' }, + { label: 'Login', value: 'login_event' }, + { label: 'Newsletter subscribe or unsubscribe', value: 'newsletter_event' }, + { label: 'Email delivered', value: 'email_delivered_event' }, + { label: 'Email opened', value: 'email_opened_event' }, + { label: 'Email link clicked', value: 'click_event' }, + { label: 'Email failed', value: 'email_failed_event' }, + { label: 'Comment', value: 'comment_event' }, + { label: 'Feedback', value: 'feedback_event' }, + { label: 'Paid subscription change', value: 'subscription_event' }, + { label: 'Payment', value: 'payment_event' }, + { label: 'Email address change', value: 'email_change_event' }, + { label: 'Donation', value: 'donation_event' }, + ], + }, + }), + filter: ghostProps.filter("data.created_at:<'2026-01-01 00:00:00'"), + limit: ghostProps.limit, + }, + async run(context) { + const { memberId, eventType, filter, limit } = context.propsValue; + const parts: string[] = []; + if (ghostCommon.hasText(memberId)) { + parts.push(`data.member_id:${ghostCommon.nqlString(memberId.trim())}`); + } + if (ghostCommon.hasText(eventType)) { + parts.push(`type:${eventType}`); + } + if (ghostCommon.hasText(filter)) { + parts.push(`(${filter.trim()})`); + } + const response = await ghostClient.request<{ events?: MemberEvent[]; meta?: unknown }>({ + auth: context.auth, + method: HttpMethod.GET, + path: '/members/events', + query: { + filter: parts.length > 0 ? parts.join('+') : undefined, + limit: ghostCommon.listQuery({ limit })['limit'], + }, + }); + const events = (response.events ?? []).map((event) => { + const { member, ...data } = event.data ?? {}; + return { + type: event.type, + id: data.id ?? null, + created_at: data.created_at ?? null, + member_id: data.member_id ?? member?.id ?? null, + member_name: member?.name ?? null, + member_email: member?.email ?? null, + data, + }; + }); + return { + events, + count: events.length, + total: ghostCommon.pagination(response.meta).total, + }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-members.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-members.ts new file mode 100644 index 000000000000..f5bf30b4972a --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-members.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostListMembersOutputSchema } from '../../output-schemas'; + +export const ghostListMembers = createAction({ + auth: ghostAuth, + name: 'ghost_list_members', + outputSchema: ghostListMembersOutputSchema, + classification: 'SEARCH', + displayName: 'List Members', + description: 'List or search members.', + audience: 'ai', + aiMetadata: { + description: + 'Lists members with their labels, newsletters and status and pagination totals. Narrow with an NQL filter (status:paid, label:vip, subscribed:true) or a free-text Search on name and email. Use Get Member by Email for an exact email.', + idempotent: true, + }, + props: { + filter: ghostProps.filter('status:paid+label:vip'), + search: Property.ShortText({ + displayName: 'Search', + description: 'Free-text search on member name and email.', + required: false, + }), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('created_at desc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'members', { + ...ghostCommon.listQuery(context.propsValue), + search: context.propsValue.search?.trim(), + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-newsletters.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-newsletters.ts new file mode 100644 index 000000000000..38239871b45c --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-newsletters.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostListNewslettersOutputSchema } from '../../output-schemas'; + +export const ghostListNewsletters = createAction({ + auth: ghostAuth, + name: 'ghost_list_newsletters', + outputSchema: ghostListNewslettersOutputSchema, + classification: 'SEARCH', + displayName: 'List Newsletters', + description: 'List the newsletters of the publication.', + audience: 'ai', + aiMetadata: { + description: + 'Lists newsletters with their ID, slug, status and settings. The slug is what Publish Post and Schedule Post need to email a post, and the ID is what the member subscribe actions need. Filter status:active to hide archived ones.', + idempotent: true, + }, + props: { + filter: ghostProps.filter('status:active'), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('sort_order asc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'newsletters', ghostCommon.listQuery(context.propsValue)); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-offers.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-offers.ts new file mode 100644 index 000000000000..e1149eb11acc --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-offers.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostResource } from '../../common/resources'; +import { ghostListOffersOutputSchema } from '../../output-schemas'; + +export const ghostListOffers = createAction({ + auth: ghostAuth, + name: 'ghost_list_offers', + outputSchema: ghostListOffersOutputSchema, + classification: 'SEARCH', + displayName: 'List Offers', + description: 'List discount and trial offers.', + audience: 'ai', + aiMetadata: { + description: + 'Lists all discount and free-trial offers with their code, discount, duration, tier and status in one call; Ghost does not paginate offers. Filter status:active to hide archived ones.', + idempotent: true, + }, + props: { + filter: ghostProps.filter('status:active'), + }, + async run(context) { + return ghostResource.list(context.auth, 'offers', { filter: context.propsValue.filter?.trim() }, false); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-pages.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-pages.ts new file mode 100644 index 000000000000..a6bb0111e507 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-pages.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostContent } from '../../common/content'; +import { ghostListPagesOutputSchema } from '../../output-schemas'; + +export const ghostListPages = createAction({ + auth: ghostAuth, + name: 'ghost_list_pages', + outputSchema: ghostListPagesOutputSchema, + classification: 'SEARCH', + displayName: 'List Pages', + description: 'List or search pages with an optional filter.', + audience: 'ai', + aiMetadata: { + description: + 'Lists static pages (not posts) of any status with pagination totals, optionally narrowed by an NQL filter. Use it to find a page ID or slug; use Get Page for the full HTML.', + idempotent: true, + }, + props: { + filter: ghostProps.filter("status:published or title:~'about'"), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('updated_at desc'), + include_content: Property.Checkbox({ + displayName: 'Include Content', + description: 'Return the HTML body of each page. Off by default to keep the result small.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + return ghostContent.list(context.auth, 'pages', context.propsValue); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-posts.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-posts.ts new file mode 100644 index 000000000000..d14733181692 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-posts.ts @@ -0,0 +1,35 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostContent } from '../../common/content'; +import { ghostListPostsOutputSchema } from '../../output-schemas'; + +export const ghostListPosts = createAction({ + auth: ghostAuth, + name: 'ghost_list_posts', + outputSchema: ghostListPostsOutputSchema, + classification: 'SEARCH', + displayName: 'List Posts', + description: 'List or search posts with an optional filter.', + audience: 'ai', + aiMetadata: { + description: + 'Lists posts of any status with pagination totals, optionally narrowed by an NQL filter such as status:draft or tag:news. Use it to find a post ID or slug; use Get Post for one post with its full HTML.', + idempotent: true, + }, + props: { + filter: ghostProps.filter("status:published+tag:news or title:~'launch'"), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('published_at desc'), + include_content: Property.Checkbox({ + displayName: 'Include Content', + description: 'Return the HTML body of each post. Off by default to keep the result small.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + return ghostContent.list(context.auth, 'posts', context.propsValue); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tags.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tags.ts new file mode 100644 index 000000000000..4400331b1338 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tags.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostListTagsOutputSchema } from '../../output-schemas'; + +export const ghostListTags = createAction({ + auth: ghostAuth, + name: 'ghost_list_tags', + outputSchema: ghostListTagsOutputSchema, + classification: 'SEARCH', + displayName: 'List Tags', + description: 'List or search post tags.', + audience: 'ai', + aiMetadata: { + description: + 'Lists post tags with their post counts and pagination totals, optionally narrowed by an NQL filter such as visibility:public. Use it to find a tag ID or slug.', + idempotent: true, + }, + props: { + filter: ghostProps.filter("visibility:public or name:~'news'"), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('name asc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'tags', { + ...ghostCommon.listQuery(context.propsValue), + include: 'count.posts', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tiers.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tiers.ts new file mode 100644 index 000000000000..5992de29da41 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-tiers.ts @@ -0,0 +1,34 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { TIER_QUERY } from '../../common/tier-props'; +import { ghostListTiersOutputSchema } from '../../output-schemas'; + +export const ghostListTiers = createAction({ + auth: ghostAuth, + name: 'ghost_list_tiers', + outputSchema: ghostListTiersOutputSchema, + classification: 'SEARCH', + displayName: 'List Tiers', + description: 'List membership tiers with prices and benefits.', + audience: 'ai', + aiMetadata: { + description: + 'Lists membership tiers (the free tier and paid tiers) with prices, benefits and active state. Filter type:paid+active:true for sellable paid tiers. The tier ID is what Create Offer needs.', + idempotent: true, + }, + props: { + filter: ghostProps.filter('type:paid+active:true'), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('monthly_price asc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'tiers', { + ...ghostCommon.listQuery(context.propsValue), + ...TIER_QUERY, + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/list-users.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-users.ts new file mode 100644 index 000000000000..13dd2693b5b4 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/list-users.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostListUsersOutputSchema } from '../../output-schemas'; + +export const ghostListUsers = createAction({ + auth: ghostAuth, + name: 'ghost_list_users', + outputSchema: ghostListUsersOutputSchema, + classification: 'SEARCH', + displayName: 'List Users', + description: 'List staff users such as authors and editors.', + audience: 'ai', + aiMetadata: { + description: + 'Lists staff users (owner, admins, editors, authors, contributors), not members, with their roles and post counts. Use it to find the author IDs that Create Post and Update Post accept.', + idempotent: true, + }, + props: { + filter: ghostProps.filter('status:active'), + limit: ghostProps.limit, + page: ghostProps.page, + order: ghostProps.order('name asc'), + }, + async run(context) { + return ghostResource.list(context.auth, 'users', { + ...ghostCommon.listQuery(context.propsValue), + include: 'roles,count.posts', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/publish-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/publish-post.ts new file mode 100644 index 000000000000..1b6f40b065a4 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/publish-post.ts @@ -0,0 +1,43 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPublishPostOutputSchema } from '../../output-schemas'; + +export const ghostPublishPost = createAction({ + auth: ghostAuth, + name: 'ghost_publish_post', + outputSchema: ghostPublishPostOutputSchema, + classification: 'WRITE', + displayName: 'Publish Post', + description: 'Publish a draft post now, optionally emailing it to a newsletter.', + audience: 'ai', + aiMetadata: { + description: + 'Publishes a post immediately. With Newsletter Slug it also emails the post to that newsletter (all members, or Email Segment), and Email Only sends the email without putting it on the site. An email can only be sent once, so do not retry after success.', + idempotent: false, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + newsletter: ghostProps.newsletterSlug, + email_segment: ghostProps.emailSegment, + email_only: ghostProps.emailOnly, + }, + async run(context) { + const { post_id, newsletter, email_segment, email_only } = context.propsValue; + const id = ghostCommon.id(post_id, 'Post ID'); + const hasNewsletter = ghostCommon.hasText(newsletter); + if (email_only && !hasNewsletter) { + throw new Error('Email Only requires Newsletter Slug.'); + } + const body: Record = { status: 'published' }; + if (email_only) { + body['email_only'] = true; + } + return ghostContent.edit(context.auth, 'posts', id, body, { + newsletter: hasNewsletter ? newsletter.trim() : undefined, + email_segment: hasNewsletter ? email_segment?.trim() || 'all' : undefined, + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/remove-member-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/remove-member-label.ts new file mode 100644 index 000000000000..e92c199f8267 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/remove-member-label.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { changeMemberLabel } from '../../common/member-labels'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostRemoveMemberLabel = createAction({ + auth: ghostAuth, + name: 'ghost_remove_member_label', + outputSchema: ghostMemberOutputSchema, + classification: 'WRITE', + displayName: 'Remove Member Label', + description: 'Remove one label from a member, keeping their other labels.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one label from a member without touching their other labels, and returns the updated member. The label itself is kept; use Delete Label to remove it everywhere. Removing a label the member does not have is a no-op.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + label_id: ghostProps.id('Label ID', 'The label ID, from List Labels or Get Member.'), + }, + async run(context) { + return changeMemberLabel({ + auth: context.auth, + memberId: context.propsValue.member_id, + labelId: context.propsValue.label_id, + action: 'removeLabel', + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/schedule-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/schedule-post.ts new file mode 100644 index 000000000000..afc33723a7b5 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/schedule-post.ts @@ -0,0 +1,51 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostSchedulePost = createAction({ + auth: ghostAuth, + name: 'ghost_schedule_post', + outputSchema: ghostPostOutputSchema, + classification: 'WRITE', + displayName: 'Schedule Post', + description: 'Schedule a draft post to publish at a future time.', + audience: 'ai', + aiMetadata: { + description: + 'Schedules a draft or already scheduled post to publish at a future Published At; Ghost rejects times in the past or only moments away. With Newsletter Slug the post is also emailed when it goes live. Use Unpublish Post to cancel.', + idempotent: false, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'When to publish. Must be in the future.', + required: true, + }), + newsletter: ghostProps.newsletterSlug, + email_segment: ghostProps.emailSegment, + email_only: ghostProps.emailOnly, + }, + async run(context) { + const { post_id, published_at, newsletter, email_segment, email_only } = context.propsValue; + const id = ghostCommon.id(post_id, 'Post ID'); + const hasNewsletter = ghostCommon.hasText(newsletter); + if (email_only && !hasNewsletter) { + throw new Error('Email Only requires Newsletter Slug.'); + } + const body: Record = { + status: 'scheduled', + published_at: ghostCommon.futureDate(published_at, 'Published At'), + }; + if (email_only) { + body['email_only'] = true; + } + return ghostContent.edit(context.auth, 'posts', id, body, { + newsletter: hasNewsletter ? newsletter.trim() : undefined, + email_segment: hasNewsletter ? email_segment?.trim() || 'all' : undefined, + }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/subscribe-member-to-newsletters.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/subscribe-member-to-newsletters.ts new file mode 100644 index 000000000000..60e27a4c09be --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/subscribe-member-to-newsletters.ts @@ -0,0 +1,31 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { currentNewsletterIds, requireIds, saveNewsletters } from '../../common/member-newsletters'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostSubscribeMemberToNewsletters = createAction({ + auth: ghostAuth, + name: 'ghost_subscribe_member_to_newsletters', + outputSchema: ghostMemberOutputSchema, + classification: 'WRITE', + displayName: 'Subscribe Member to Newsletters', + description: 'Add newsletter subscriptions to a member, keeping existing ones.', + audience: 'ai', + aiMetadata: { + description: + 'Subscribes a member to one or more newsletters while keeping their current subscriptions, and returns the updated member. Newsletter IDs come from List Newsletters. Re-subscribing is a no-op. Ghost has no single-newsletter subscribe call, so this reads the member subscription list and saves it back in one step; a change to that member subscription list made in the same moment can be overwritten.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + newsletter_ids: ghostProps.newsletterIds('Newsletter IDs', 'The newsletters to add, from List Newsletters.', true), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.member_id, 'Member ID'); + const add = requireIds(context.propsValue.newsletter_ids); + const current = await currentNewsletterIds({ auth: context.auth, memberId: id }); + return saveNewsletters({ auth: context.auth, memberId: id, ids: Array.from(new Set([...current, ...add])) }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/unpublish-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/unpublish-post.ts new file mode 100644 index 000000000000..32624b2b4a84 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/unpublish-post.ts @@ -0,0 +1,28 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostUnpublishPost = createAction({ + auth: ghostAuth, + name: 'ghost_unpublish_post', + outputSchema: ghostPostOutputSchema, + classification: 'WRITE', + displayName: 'Unpublish Post', + description: 'Move a published or scheduled post back to draft.', + audience: 'ai', + aiMetadata: { + description: + 'Sets a published or scheduled post back to draft, which removes it from the site and cancels a pending scheduled publish and its email. An email that was already sent cannot be recalled. Safe to retry.', + idempotent: true, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.post_id, 'Post ID'); + return ghostContent.edit(context.auth, 'posts', id, { status: 'draft' }); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/unsubscribe-member-from-newsletters.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/unsubscribe-member-from-newsletters.ts new file mode 100644 index 000000000000..3fc5cc784349 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/unsubscribe-member-from-newsletters.ts @@ -0,0 +1,48 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { requireIds, saveNewsletters, unsubscribeFromNewsletter } from '../../common/member-newsletters'; +import { ghostResource } from '../../common/resources'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostUnsubscribeMemberFromNewsletters = createAction({ + auth: ghostAuth, + name: 'ghost_unsubscribe_member_from_newsletters', + outputSchema: ghostMemberOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Unsubscribe Member from Newsletters', + description: 'Remove newsletter subscriptions from a member.', + audience: 'ai', + aiMetadata: { + description: + 'Unsubscribes a member from the given newsletters, or from every newsletter when Unsubscribe from All is on, keeping the member account. Each newsletter is removed on its own in Ghost, so a subscription added to the member at the same time is never lost. Returns the updated member. Unsubscribing twice is a no-op.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + newsletter_ids: ghostProps.newsletterIds( + 'Newsletter IDs', + 'The newsletters to remove, from Get Member or List Newsletters.', + false + ), + unsubscribe_from_all: Property.Checkbox({ + displayName: 'Unsubscribe from All', + description: 'Remove every newsletter subscription. Newsletter IDs are ignored when on.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.member_id, 'Member ID'); + if (context.propsValue.unsubscribe_from_all === true) { + return saveNewsletters({ auth: context.auth, memberId: id, ids: [] }); + } + const remove = Array.from(new Set(requireIds(context.propsValue.newsletter_ids))); + await ghostResource.get(context.auth, 'members', id); + for (const newsletterId of remove) { + await unsubscribeFromNewsletter({ auth: context.auth, memberId: id, newsletterId }); + } + return ghostResource.get(context.auth, 'members', id); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-label.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-label.ts new file mode 100644 index 000000000000..f82081ce8a94 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-label.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostCreateLabelOutputSchema } from '../../output-schemas'; + +export const ghostUpdateLabel = createAction({ + auth: ghostAuth, + name: 'ghost_update_label', + outputSchema: ghostCreateLabelOutputSchema, + classification: 'WRITE', + displayName: 'Update Label', + description: 'Rename a member label.', + audience: 'ai', + aiMetadata: { + description: + 'Renames a member label by ID; members keep the label. Filters that reference the old label slug may stop matching.', + idempotent: true, + }, + props: { + label_id: ghostProps.id('Label ID', 'The label ID, from List Labels.'), + name: Property.ShortText({ + displayName: 'Name', + description: 'The new label name.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'The new label slug.', + required: false, + }), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.label_id, 'Label ID'); + return ghostResource.edit(context.auth, 'labels', id, ghostResource.pick(context.propsValue, ['name', 'slug'])); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-member.ts new file mode 100644 index 000000000000..e5881e1da19f --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-member.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { applyClearFields, clearFieldsProp } from '../../common/clear-fields'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostMemberOutputSchema } from '../../output-schemas'; + +export const ghostUpdateMember = createAction({ + auth: ghostAuth, + name: 'ghost_update_member', + outputSchema: ghostMemberOutputSchema, + classification: 'WRITE', + displayName: 'Update Member', + description: 'Update the details, labels or newsletters of a member.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a member by ID; only the inputs you supply change, and Clear Fields blanks the name or note. Labels and Newsletter IDs replace the full list, and an empty list removes all of them; to add or remove one item use Add Member Label, Remove Member Label, Subscribe Member to Newsletters or Unsubscribe Member from Newsletters.', + idempotent: true, + }, + props: { + member_id: ghostProps.id('Member ID', 'The member ID, from List Members or Get Member by Email.'), + email: Property.ShortText({ + displayName: 'Email', + description: 'The new email address.', + required: false, + }), + name: Property.ShortText({ + displayName: 'Name', + description: 'The new full name.', + required: false, + }), + note: Property.LongText({ + displayName: 'Note', + description: 'The new private staff note.', + required: false, + }), + labels: Property.Array({ + displayName: 'Labels', + description: 'The complete list of label names the member should have. Missing labels are created, and an empty list removes every label.', + required: false, + }), + newsletter_ids: ghostProps.newsletterIds( + 'Newsletter IDs', + 'The complete list of newsletter IDs the member should be subscribed to. An empty list unsubscribes the member from every newsletter.', + false + ), + clear_fields: clearFieldsProp([ + { label: 'Name', value: 'name' }, + { label: 'Note', value: 'note' }, + ]), + }, + async run(context) { + const { member_id, email, name, note, labels, newsletter_ids, clear_fields } = context.propsValue; + const id = ghostCommon.id(member_id, 'Member ID'); + const body = applyClearFields({ + body: ghostResource.pick({ email, name, note }, ['email', 'name', 'note']), + clear: clear_fields, + allowed: ['name', 'note'], + clearValue: '', + }); + if (Array.isArray(labels)) { + body['labels'] = (ghostCommon.stringList(labels) ?? []).map((label) => ({ name: label })); + } + if (Array.isArray(newsletter_ids)) { + body['newsletters'] = (ghostCommon.stringList(newsletter_ids) ?? []).map((newsletterId) => ({ id: newsletterId })); + } + return ghostResource.edit(context.auth, 'members', id, body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-newsletter.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-newsletter.ts new file mode 100644 index 000000000000..d551976289f4 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-newsletter.ts @@ -0,0 +1,45 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { applyClearFields, clearFieldsProp } from '../../common/clear-fields'; +import { ghostCommon } from '../../common/client'; +import { NEWSLETTER_FIELDS, newsletterProps } from '../../common/newsletter-props'; +import { ghostResource } from '../../common/resources'; +import { ghostNewsletterOutputSchema } from '../../output-schemas'; + +export const ghostUpdateNewsletter = createAction({ + auth: ghostAuth, + name: 'ghost_update_newsletter', + outputSchema: ghostNewsletterOutputSchema, + classification: 'WRITE', + displayName: 'Update Newsletter', + description: 'Update the name or settings of a newsletter.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a newsletter by ID; only the inputs you supply change, and Clear Fields blanks the description or sender name. It cannot archive the newsletter (use Archive Newsletter) or change the sender email, which Ghost must verify by email first.', + idempotent: true, + }, + props: { + newsletter_id: ghostProps.id('Newsletter ID', 'The newsletter ID, from List Newsletters.'), + ...newsletterProps('update'), + clear_fields: clearFieldsProp([ + { label: 'Description', value: 'description' }, + { label: 'Sender Name', value: 'sender_name' }, + ]), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.newsletter_id, 'Newsletter ID'); + const body = applyClearFields({ + body: ghostResource.pick(context.propsValue, NEWSLETTER_FIELDS), + clear: context.propsValue.clear_fields, + allowed: ['description', 'sender_name'], + clearValue: null, + }); + const subscribeOnSignup = ghostCommon.triState(context.propsValue.subscribe_on_signup); + if (subscribeOnSignup !== undefined) { + body['subscribe_on_signup'] = subscribeOnSignup; + } + return ghostResource.edit(context.auth, 'newsletters', id, body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-offer.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-offer.ts new file mode 100644 index 000000000000..f93f15106655 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-offer.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { applyClearFields, clearFieldsProp } from '../../common/clear-fields'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { ghostOfferOutputSchema } from '../../output-schemas'; + +export const ghostUpdateOffer = createAction({ + auth: ghostAuth, + name: 'ghost_update_offer', + outputSchema: ghostOfferOutputSchema, + classification: 'WRITE', + displayName: 'Update Offer', + description: 'Update the name, code or display text of an offer.', + audience: 'ai', + aiMetadata: { + description: + 'Edits an offer by ID; only the name, code and display text can change, not the discount terms, and Clear Fields blanks the display description. Changing the code breaks links that use the old code. Use Archive Offer to stop redemptions.', + idempotent: true, + }, + props: { + offer_id: ghostProps.id('Offer ID', 'The offer ID, from List Offers.'), + name: Property.ShortText({ + displayName: 'Name', + description: 'The new internal name.', + required: false, + }), + code: Property.ShortText({ + displayName: 'Code', + description: 'The new offer code.', + required: false, + }), + display_title: Property.ShortText({ + displayName: 'Display Title', + description: 'The new headline shown on the offer page.', + required: false, + }), + display_description: Property.LongText({ + displayName: 'Display Description', + description: 'The new text shown on the offer page.', + required: false, + }), + clear_fields: clearFieldsProp([{ label: 'Display Description', value: 'display_description' }]), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.offer_id, 'Offer ID'); + return ghostResource.edit( + context.auth, + 'offers', + id, + applyClearFields({ + body: ghostResource.pick(context.propsValue, ['name', 'code', 'display_title', 'display_description']), + clear: context.propsValue.clear_fields, + allowed: ['display_description'], + clearValue: null, + }) + ); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-page.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-page.ts new file mode 100644 index 000000000000..1fe28d809dec --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-page.ts @@ -0,0 +1,59 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { contentProps, ghostContent } from '../../common/content'; +import { ghostPageOutputSchema } from '../../output-schemas'; + +export const ghostUpdatePage = createAction({ + auth: ghostAuth, + name: 'ghost_update_page', + outputSchema: ghostPageOutputSchema, + classification: 'WRITE', + displayName: 'Update Page', + description: 'Update the content, settings or status of a page.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a static page by ID; only the inputs you supply change, and Status can publish, schedule or unpublish it because pages are never emailed. Tags and Authors replace the whole list; an empty Tags list removes every tag, and Authors can never be empty. Clear Fields blanks the excerpt, feature image or SEO fields.', + idempotent: true, + }, + props: { + page_id: ghostProps.id('Page ID', 'The page ID, from List Pages.'), + ...contentProps({ label: 'page', mode: 'update' }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Change the page status. Scheduled requires a future Published At. Leave empty to keep it.', + required: false, + options: { + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Scheduled', value: 'scheduled' }, + ], + }, + }), + published_at: Property.DateTime({ + displayName: 'Published At', + description: 'The publish time. Must be in the future when Status is Scheduled.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const id = ghostCommon.id(props.page_id, 'Page ID'); + const body = ghostContent.body(props); + if (ghostCommon.hasText(props.status)) { + body['status'] = props.status; + } + if (props.status === 'scheduled') { + body['published_at'] = ghostCommon.futureDate(props.published_at, 'Published At'); + } else if (ghostCommon.hasText(props.published_at)) { + body['published_at'] = props.published_at; + } + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to change.'); + } + return ghostContent.edit(context.auth, 'pages', id, body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-post.ts new file mode 100644 index 000000000000..4a7e35049d18 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-post.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { ghostCommon } from '../../common/client'; +import { contentProps, ghostContent } from '../../common/content'; +import { ghostPostOutputSchema } from '../../output-schemas'; + +export const ghostUpdatePost = createAction({ + auth: ghostAuth, + name: 'ghost_update_post', + outputSchema: ghostPostOutputSchema, + classification: 'WRITE', + displayName: 'Update Post', + description: 'Update the content or settings of a post.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a post by ID; only the inputs you supply change. Tags and Authors replace the whole list, so pass the full list you want; an empty Tags list removes every tag, and Authors can never be empty. Clear Fields blanks the excerpt, feature image or SEO fields. It cannot change status: use Publish Post, Schedule Post or Unpublish Post for that.', + idempotent: true, + }, + props: { + post_id: ghostProps.id('Post ID', 'The post ID, from List Posts.'), + ...contentProps({ label: 'post', mode: 'update' }), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.post_id, 'Post ID'); + const body = ghostContent.body(context.propsValue); + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to change.'); + } + return ghostContent.edit(context.auth, 'posts', id, body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tag.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tag.ts new file mode 100644 index 000000000000..0dc36aa4e9b5 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tag.ts @@ -0,0 +1,43 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { applyClearFields, clearFieldsProp } from '../../common/clear-fields'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { TAG_FIELDS, tagProps } from '../../common/tag-props'; +import { ghostCreateTagOutputSchema } from '../../output-schemas'; + +export const ghostUpdateTag = createAction({ + auth: ghostAuth, + name: 'ghost_update_tag', + outputSchema: ghostCreateTagOutputSchema, + classification: 'WRITE', + displayName: 'Update Tag', + description: 'Update the name or settings of a tag.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a tag by ID; only the inputs you supply change, and Clear Fields blanks the description, accent color or SEO fields. Changing the slug changes the tag page URL.', + idempotent: true, + }, + props: { + tag_id: ghostProps.id('Tag ID', 'The tag ID, from List Tags.'), + ...tagProps('update'), + clear_fields: clearFieldsProp([ + { label: 'Description', value: 'description' }, + { label: 'Accent Color', value: 'accent_color' }, + { label: 'Meta Title', value: 'meta_title' }, + { label: 'Meta Description', value: 'meta_description' }, + ]), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.tag_id, 'Tag ID'); + const body = applyClearFields({ + body: ghostResource.pick(context.propsValue, TAG_FIELDS), + clear: context.propsValue.clear_fields, + allowed: ['description', 'accent_color', 'meta_title', 'meta_description'], + clearValue: null, + }); + return ghostResource.edit(context.auth, 'tags', id, body); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tier.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tier.ts new file mode 100644 index 000000000000..2e530806db5c --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/update-tier.ts @@ -0,0 +1,42 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostProps } from '../../common/ai-props'; +import { applyClearFields, clearFieldsProp } from '../../common/clear-fields'; +import { ghostCommon } from '../../common/client'; +import { ghostResource } from '../../common/resources'; +import { tierBody } from '../../common/tier-body'; +import { TIER_QUERY, tierProps } from '../../common/tier-props'; +import { ghostTierOutputSchema } from '../../output-schemas'; + +export const ghostUpdateTier = createAction({ + auth: ghostAuth, + name: 'ghost_update_tier', + outputSchema: ghostTierOutputSchema, + classification: 'WRITE', + displayName: 'Update Tier', + description: 'Update the prices, benefits or settings of a tier.', + audience: 'ai', + aiMetadata: { + description: + 'Edits a membership tier by ID; only the inputs you supply change, Benefits replaces the full list (an empty list removes every benefit), and Clear Fields blanks the description or welcome page URL. New prices apply to new subscribers only and change the public pricing page. Use Archive Tier to stop selling it.', + idempotent: true, + }, + props: { + tier_id: ghostProps.id('Tier ID', 'The tier ID, from List Tiers.'), + ...tierProps('update'), + clear_fields: clearFieldsProp([ + { label: 'Description', value: 'description' }, + { label: 'Welcome Page URL', value: 'welcome_page_url' }, + ]), + }, + async run(context) { + const id = ghostCommon.id(context.propsValue.tier_id, 'Tier ID'); + const body = applyClearFields({ + body: tierBody(context.propsValue), + clear: context.propsValue.clear_fields, + allowed: ['description', 'welcome_page_url'], + clearValue: null, + }); + return ghostResource.edit(context.auth, 'tiers', id, body, TIER_QUERY); + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-file.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-file.ts new file mode 100644 index 000000000000..7a8575a19c3a --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-file.ts @@ -0,0 +1,56 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import FormData from 'form-data'; +import { ghostUploadFileOutputSchema } from '../../output-schemas'; + +export const ghostUploadFile = createAction({ + auth: ghostAuth, + name: 'ghost_upload_file', + outputSchema: ghostUploadFileOutputSchema, + classification: 'WRITE', + displayName: 'Upload File', + description: 'Upload a downloadable file (PDF, ZIP, CSV and more) to Ghost and get its URL.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a file such as a PDF, ZIP, CSV, DOCX or EPUB to Ghost and returns its public URL, to link from a post as a file card or download. Ghost only accepts known extensions; zip anything else. Use Upload Image or Upload Media for pictures, video and audio. Each call stores a new file.', + idempotent: false, + }, + props: { + file: Property.File({ + displayName: 'File', + description: 'The file to upload. Its name must end with a supported extension, e.g. .pdf or .zip.', + required: true, + }), + ref: Property.ShortText({ + displayName: 'Reference', + description: 'An optional value returned unchanged, e.g. the original file name.', + required: false, + }), + }, + async run(context) { + const { file, ref } = context.propsValue; + const filename = file.filename || 'file'; + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.base64, 'base64'); + const form = new FormData(); + form.append('file', data, { filename, contentType: 'application/octet-stream' }); + if (ref && ref.trim()) { + form.append('ref', ref.trim()); + } + const response = await ghostClient.request<{ + files?: { url: string; ref: string | null }[]; + }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/files/upload', + body: form, + }); + const uploaded = response.files?.[0]; + if (!uploaded) { + throw new Error('Ghost did not return the uploaded file.'); + } + return { url: uploaded.url, ref: uploaded.ref ?? null }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-image.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-image.ts new file mode 100644 index 000000000000..0fe18b220329 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-image.ts @@ -0,0 +1,84 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import FormData from 'form-data'; +import { ghostUploadImageOutputSchema } from '../../output-schemas'; + +const MIME_TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + svgz: 'image/svg+xml', + ico: 'image/x-icon', +}; + +export const ghostUploadImage = createAction({ + auth: ghostAuth, + name: 'ghost_upload_image', + outputSchema: ghostUploadImageOutputSchema, + classification: 'WRITE', + displayName: 'Upload Image', + description: 'Upload an image to Ghost and get its URL.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a PNG, JPG, GIF, WebP, SVG or ICO image to the Ghost media library and returns its public URL, to use as a post Feature Image URL or inside post HTML. Each call stores a new file.', + idempotent: false, + }, + props: { + file: Property.File({ + displayName: 'Image', + description: 'The image file to upload.', + required: true, + }), + purpose: Property.StaticDropdown({ + displayName: 'Purpose', + description: 'How Ghost validates the image. Defaults to a content image.', + required: false, + defaultValue: 'image', + options: { + options: [ + { label: 'Content or feature image', value: 'image' }, + { label: 'Profile image (square)', value: 'profile_image' }, + { label: 'Site icon (square)', value: 'icon' }, + ], + }, + }), + ref: Property.ShortText({ + displayName: 'Reference', + description: 'An optional value returned unchanged, e.g. the original file name.', + required: false, + }), + }, + async run(context) { + const { file, purpose, ref } = context.propsValue; + const filename = file.filename || 'image'; + const extension = (file.extension || filename.split('.').pop() || '').toLowerCase(); + const mimeType = MIME_TYPES[extension]; + if (!mimeType) { + throw new Error(`Unsupported image type "${extension}". Use PNG, JPG, GIF, WebP, SVG or ICO.`); + } + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.base64, 'base64'); + const form = new FormData(); + form.append('file', data, { filename, contentType: mimeType }); + form.append('purpose', purpose || 'image'); + if (ref && ref.trim()) { + form.append('ref', ref.trim()); + } + const response = await ghostClient.request<{ images?: { url: string; ref: string | null }[] }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/images/upload', + body: form, + }); + const image = response.images?.[0]; + if (!image) { + throw new Error('Ghost did not return the uploaded image.'); + } + return { url: image.url, ref: image.ref ?? null }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-media.ts b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-media.ts new file mode 100644 index 000000000000..3ca431c0b55e --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/actions/ai/upload-media.ts @@ -0,0 +1,77 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ghostAuth } from '../../auth'; +import { ghostClient } from '../../common/client'; +import FormData from 'form-data'; +import { ghostUploadMediaOutputSchema } from '../../output-schemas'; + +const MIME_TYPES: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + ogv: 'video/ogg', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + m4a: 'audio/mp4', +}; + +export const ghostUploadMedia = createAction({ + auth: ghostAuth, + name: 'ghost_upload_media', + outputSchema: ghostUploadMediaOutputSchema, + classification: 'WRITE', + displayName: 'Upload Media', + description: 'Upload a video or audio file to Ghost and get its URL.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads an MP4, WebM, OGV, MP3, WAV, OGG or M4A file to the Ghost media library and returns its public URL, to embed in post HTML as a video or audio card. Use Upload Image for pictures and Upload File for any other download. Each call stores a new file.', + idempotent: false, + }, + props: { + file: Property.File({ + displayName: 'Media File', + description: 'The video or audio file to upload.', + required: true, + }), + ref: Property.ShortText({ + displayName: 'Reference', + description: 'An optional value returned unchanged, e.g. the original file name.', + required: false, + }), + }, + async run(context) { + const { file, ref } = context.propsValue; + const filename = file.filename || 'media'; + const extension = (file.extension || filename.split('.').pop() || '').toLowerCase(); + const mimeType = MIME_TYPES[extension]; + if (!mimeType) { + throw new Error( + `Unsupported media type "${extension}". Use MP4, WebM, OGV, MP3, WAV, OGG or M4A.` + ); + } + const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.base64, 'base64'); + const form = new FormData(); + form.append('file', data, { filename, contentType: mimeType }); + if (ref && ref.trim()) { + form.append('ref', ref.trim()); + } + const response = await ghostClient.request<{ + media?: { url: string; thumbnail_url: string | null; ref: string | null }[]; + }>({ + auth: context.auth, + method: HttpMethod.POST, + path: '/media/upload', + body: form, + }); + const media = response.media?.[0]; + if (!media) { + throw new Error('Ghost did not return the uploaded media.'); + } + return { + url: media.url, + thumbnail_url: media.thumbnail_url ?? null, + ref: media.ref ?? null, + }; + }, +}); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/create-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/create-member.ts index f1fbeb0100f9..302aa2a6931f 100644 --- a/packages/pieces/community/ghostcms/src/lib/actions/create-member.ts +++ b/packages/pieces/community/ghostcms/src/lib/actions/create-member.ts @@ -1,15 +1,17 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; import { common } from '../common'; +import { ghostOriginalMembersOutputSchema } from '../output-schemas'; export const createMember = createAction({ name: 'create_member', + outputSchema: ghostOriginalMembersOutputSchema, classification: 'WRITE', displayName: 'Create Member', description: 'Create a new member', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new member in a Ghost publication from an email (required), with optional name, internal note, and newsletter subscriptions. Use to add a subscriber/member to the audience. Not idempotent: each call creates a member and re-running with the same email may produce a duplicate or error.', idempotent: false }, auth: ghostAuth, props: { diff --git a/packages/pieces/community/ghostcms/src/lib/actions/create-post.ts b/packages/pieces/community/ghostcms/src/lib/actions/create-post.ts index 50813d8501cf..9186719f74e8 100644 --- a/packages/pieces/community/ghostcms/src/lib/actions/create-post.ts +++ b/packages/pieces/community/ghostcms/src/lib/actions/create-post.ts @@ -5,15 +5,17 @@ import { } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; import { common } from '../common'; +import { ghostOriginalCreatePostOutputSchema } from '../output-schemas'; export const createPost = createAction({ name: 'create_post', + outputSchema: ghostOriginalCreatePostOutputSchema, classification: 'WRITE', displayName: 'Create Post', description: 'Create a new post', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new post in a Ghost publication from a title and HTML content, with the status controlling whether it is saved as a draft, published immediately, or scheduled (scheduled requires a future Published At). Supports optional slug, excerpt, author, tags, and a featured flag. Not idempotent: each call creates a separate post.', idempotent: false }, auth: ghostAuth, props: { diff --git a/packages/pieces/community/ghostcms/src/lib/actions/find-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/find-member.ts index b7d8a8e58ba0..dd4bd71a61a6 100644 --- a/packages/pieces/community/ghostcms/src/lib/actions/find-member.ts +++ b/packages/pieces/community/ghostcms/src/lib/actions/find-member.ts @@ -1,15 +1,18 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; import { common } from '../common'; +import { ghostCommon } from '../common/client'; +import { ghostOriginalFindMemberOutputSchema } from '../output-schemas'; export const findMember = createAction({ name: 'find_member', + outputSchema: ghostOriginalFindMemberOutputSchema, classification: 'READ', displayName: 'Find Member', description: 'Find a member by email', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Looks up Ghost members filtered by an exact email address and returns the matches. Use to check whether a member exists or to resolve a member id before updating. Read-only and idempotent.', idempotent: true }, auth: ghostAuth, props: { @@ -27,7 +30,7 @@ export const findMember = createAction({ Authorization: `Ghost ${common.jwtFromApiKey(context.auth.props.apiKey)}`, }, queryParams: { - filter: `email:${context.propsValue.email}`, + filter: `email:${ghostCommon.nqlString(context.propsValue.email.trim())}`, }, }); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/find-user.ts b/packages/pieces/community/ghostcms/src/lib/actions/find-user.ts index 1f399154aeec..76902e842daa 100644 --- a/packages/pieces/community/ghostcms/src/lib/actions/find-user.ts +++ b/packages/pieces/community/ghostcms/src/lib/actions/find-user.ts @@ -1,15 +1,18 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; import { common } from '../common'; +import { ghostCommon } from '../common/client'; +import { ghostOriginalFindUserOutputSchema } from '../output-schemas'; export const findUser = createAction({ name: 'find_user', + outputSchema: ghostOriginalFindUserOutputSchema, classification: 'READ', displayName: 'Find User', description: 'Find a staff user by email', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Looks up Ghost staff users (authors/admins, not members) filtered by an exact email address and returns the matches. Use to resolve a staff user id, e.g. to set as a post author. Read-only and idempotent.', idempotent: true }, auth: ghostAuth, props: { @@ -27,7 +30,7 @@ export const findUser = createAction({ Authorization: `Ghost ${common.jwtFromApiKey(context.auth.props.apiKey)}`, }, queryParams: { - filter: `email:${context.propsValue.email}`, + filter: `email:${ghostCommon.nqlString(context.propsValue.email.trim())}`, }, }); diff --git a/packages/pieces/community/ghostcms/src/lib/actions/update-member.ts b/packages/pieces/community/ghostcms/src/lib/actions/update-member.ts index 2e52beece54f..4955d30307a8 100644 --- a/packages/pieces/community/ghostcms/src/lib/actions/update-member.ts +++ b/packages/pieces/community/ghostcms/src/lib/actions/update-member.ts @@ -1,15 +1,17 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; import { common } from '../common'; +import { ghostOriginalMembersOutputSchema } from '../output-schemas'; export const updateMember = createAction({ name: 'update_member', + outputSchema: ghostOriginalMembersOutputSchema, classification: 'WRITE', displayName: 'Update Member', description: 'Update a member', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing Ghost member identified by member id, changing any supplied fields (email, name, note, newsletter subscriptions); omitted fields are left unchanged. Use to edit a known member. Requires the target member id. Idempotent: re-applying the same field values yields the same member state.', idempotent: true }, auth: ghostAuth, props: { @@ -50,7 +52,7 @@ export const updateMember = createAction({ if (context.propsValue.name) data.name = context.propsValue.name; if (context.propsValue.note) data.note = context.propsValue.note; if (context.propsValue.newsletters) - data.newsletters = context.propsValue.newsletters; + data.newsletters = newsletters; const response = await httpClient.sendRequest({ url: `${context.auth.props.baseUrl}/ghost/api/admin/members/${context.propsValue.member}`, diff --git a/packages/pieces/community/ghostcms/src/lib/auth.ts b/packages/pieces/community/ghostcms/src/lib/auth.ts new file mode 100644 index 000000000000..c104a92bf7ca --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/auth.ts @@ -0,0 +1,71 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { GhostApiError, ghostClient } from './common/client'; + +const authMarkdown = ` +To generate an API key, follow the steps below in GhostCMS: +1. Go to Settings -> Advanced -> Integrations. +2. Scroll down to Custom Integrations and click Add custom integration. +3. Enter integration name and click create. +4. Copy the API URL and the Admin API Key into the fields below. +`; + +export const ghostAuth = PieceAuth.CustomAuth({ + description: authMarkdown, + required: true, + props: { + baseUrl: Property.ShortText({ + displayName: 'API URL', + description: + 'The API URL of your application (https://test-publication.ghost.io)', + required: true, + }), + apiKey: PieceAuth.SecretText({ + displayName: 'Admin API Key', + description: 'The admin API key for your application', + required: true, + }), + }, + validate: async ({ auth }) => { + const urlProblem = baseUrlProblem(auth.baseUrl); + if (urlProblem) { + return { valid: false, error: urlProblem }; + } + try { + await ghostClient.request({ + auth: { props: { baseUrl: auth.baseUrl, apiKey: auth.apiKey } }, + method: HttpMethod.GET, + path: '/users/', + query: { limit: 1 }, + }); + return { valid: true }; + } catch (error) { + if (error instanceof GhostApiError && (error.status === 401 || error.status === 403)) { + return { valid: false, error: 'Invalid Admin API Key. Copy the Admin API Key of the custom integration from Ghost Settings > Advanced > Integrations.' }; + } + return { + valid: false, + error: error instanceof Error ? `Could not verify the connection: ${error.message}` : 'Could not verify the connection.', + }; + } + }, +}); + +function baseUrlProblem(value: string): string | null { + const parsed = parseUrl((value ?? '').trim()); + if (!parsed || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) { + return 'API URL must be the http(s) address of your Ghost site, e.g. https://your-site.ghost.io.'; + } + if (parsed.username || parsed.password) { + return 'API URL must not contain a username or password.'; + } + return null; +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} diff --git a/packages/pieces/community/ghostcms/src/lib/common/ai-props.ts b/packages/pieces/community/ghostcms/src/lib/common/ai-props.ts new file mode 100644 index 000000000000..12da9cdb8b05 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/ai-props.ts @@ -0,0 +1,77 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const ghostProps = { + filter: (example: string) => + Property.ShortText({ + displayName: 'Filter', + description: `A Ghost NQL filter, e.g. ${example}. Quote values that contain spaces or special characters with single quotes.`, + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'How many records to return per page, from 1 to 100. Defaults to 15.', + required: false, + defaultValue: 15, + }), + page: Property.Number({ + displayName: 'Page', + description: 'The page number to return. Defaults to 1.', + required: false, + defaultValue: 1, + }), + order: (example: string) => + Property.ShortText({ + displayName: 'Order', + description: `Sort order as " asc|desc", e.g. ${example}.`, + required: false, + }), + id: (displayName: string, description: string) => + Property.ShortText({ + displayName, + description, + required: true, + }), + slug: (description: string) => + Property.ShortText({ + displayName: 'Slug', + description, + required: true, + }), + triState: (displayName: string, description: string) => + Property.StaticDropdown({ + displayName, + description: `${description} Leave empty to keep the current value.`, + required: false, + options: { + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], + }, + }), + newsletterSlug: Property.ShortText({ + displayName: 'Newsletter Slug', + description: + 'Send the post by email to this newsletter, e.g. "default-newsletter". Get slugs from List Newsletters. Leave empty to publish on the site only.', + required: false, + }), + emailSegment: Property.ShortText({ + displayName: 'Email Segment', + description: + 'Which members receive the email: "all", "status:free", "status:-free" (paid) or another member NQL filter. Only used with Newsletter Slug. Defaults to all.', + required: false, + }), + emailOnly: Property.Checkbox({ + displayName: 'Email Only', + description: + 'Send the post as an email to the newsletter without publishing it on the site. Requires Newsletter Slug.', + required: false, + defaultValue: false, + }), + newsletterIds: (displayName: string, description: string, required: boolean) => + Property.Array({ + displayName, + description, + required, + }), +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/clear-fields.ts b/packages/pieces/community/ghostcms/src/lib/common/clear-fields.ts new file mode 100644 index 000000000000..41f7eabbae62 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/clear-fields.ts @@ -0,0 +1,37 @@ +import { Property } from '@activepieces/pieces-framework'; +import { ghostCommon } from './client'; + +type ClearableField = { label: string; value: string }; + +export const clearFieldsProp = (fields: ClearableField[]) => + Property.StaticMultiSelectDropdown({ + displayName: 'Clear Fields', + description: + 'Fields to blank out on the record. A field listed here must not also be given a new value. Leave empty to clear nothing.', + required: false, + options: { options: fields }, + }); + +export const applyClearFields = ({ + body, + clear, + allowed, + clearValue, +}: { + body: Record; + clear: unknown; + allowed: string[]; + clearValue: null | ''; +}): Record => { + const fields = ghostCommon.stringList(clear) ?? []; + for (const field of fields) { + if (!allowed.includes(field)) { + throw new Error(`"${field}" cannot be cleared. Clearable fields: ${allowed.join(', ')}.`); + } + if (body[field] !== undefined) { + throw new Error(`"${field}" is both set to a new value and listed in Clear Fields. Choose one.`); + } + body[field] = clearValue; + } + return body; +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/client.ts b/packages/pieces/community/ghostcms/src/lib/common/client.ts new file mode 100644 index 000000000000..b8fecd97d3e3 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/client.ts @@ -0,0 +1,218 @@ +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; +import jwt from 'jsonwebtoken'; + +type QueryValue = string | number | boolean | undefined | null; + +export type GhostAuthValue = { + props: { + baseUrl: string; + apiKey: string; + }; +}; + +export type GhostPagination = { + page: number | null; + limit: number | string | null; + total_pages: number | null; + total: number | null; + next: number | null; + prev: number | null; +}; + +export class GhostApiError extends Error { + constructor(message: string, public readonly status: number) { + super(message); + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const errorText = (error: Record): string => + [error['message'], error['context']] + .filter((part): part is string => typeof part === 'string' && part.length > 0) + .join(' '); + +const describeError = ({ status, body }: { status: number; body: unknown }): string => { + const errors = isRecord(body) && Array.isArray(body['errors']) ? body['errors'].filter(isRecord) : []; + const detail = + errors.length > 0 + ? errors.map(errorText).join('; ') + : typeof body === 'string' + ? body + : JSON.stringify(body); + if (status === 401 || status === 403) { + return `Ghost rejected the Admin API key (${status}). Check the API URL and the Admin API Key of the custom integration. ${detail}`; + } + if (status === 404) { + return `Ghost could not find the requested resource (404). Check the ID or slug. ${detail}`; + } + if (status === 409) { + return `Ghost reported an update collision (409): the record changed while it was being saved. Retry the action. ${detail}`; + } + if (status === 422) { + return `Ghost rejected the request (422). ${detail}`; + } + if (status === 429) { + return `Ghost rate limit reached (429). Wait a moment and retry. ${detail}`; + } + return `Ghost API request failed (${status}). ${detail}`; +}; + +const signToken = (apiKey: string): string => { + const [id, secret] = (apiKey ?? '').trim().split(':'); + if (!id || !secret) { + throw new Error( + 'The Admin API Key must look like ":". Copy the Admin API Key of the custom integration from Ghost Settings > Advanced > Integrations.' + ); + } + return jwt.sign({}, Buffer.from(secret, 'hex'), { + keyid: id, + expiresIn: '5m', + audience: '/admin/', + }); +}; + +const toQueryParams = (query?: Record): Record => { + const params: Record = {}; + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined && value !== null && value !== '') { + params[key] = String(value); + } + } + return params; +}; + +export const ghostClient = { + adminUrl(auth: GhostAuthValue): string { + return `${auth.props.baseUrl.trim().replace(/\/+$/, '')}/ghost/api/admin`; + }, + async request({ + auth, + method, + path, + query, + body, + headers, + }: { + auth: GhostAuthValue; + method: HttpMethod; + path: string; + query?: Record; + body?: unknown; + headers?: Record; + }): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${ghostClient.adminUrl(auth)}${path}`, + headers: { + ...(headers ?? {}), + Authorization: `Ghost ${signToken(auth.props.apiKey)}`, + }, + queryParams: toQueryParams(query), + body, + }); + return response.body; + } catch (error) { + const response = isRecord(error) && isRecord(error['response']) ? error['response'] : undefined; + const status = response && typeof response['status'] === 'number' ? response['status'] : undefined; + if (response && status) { + throw new GhostApiError(describeError({ status, body: response['body'] }), status); + } + throw error; + } + }, +}; + +export const ghostCommon = { + pagination(meta: unknown): GhostPagination { + const pagination = isRecord(meta) && isRecord(meta['pagination']) ? meta['pagination'] : {}; + const numberOrNull = (value: unknown): number | null => (typeof value === 'number' ? value : null); + const limit = pagination['limit']; + return { + page: numberOrNull(pagination['page']), + limit: typeof limit === 'number' || typeof limit === 'string' ? limit : null, + total_pages: numberOrNull(pagination['pages']), + total: numberOrNull(pagination['total']), + next: numberOrNull(pagination['next']), + prev: numberOrNull(pagination['prev']), + }; + }, + isRecord, + records(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; + }, + listQuery(props: { + filter?: string | null; + limit?: number | null; + page?: number | null; + order?: string | null; + }): Record { + const limit = + props.limit === undefined || props.limit === null + ? 15 + : Math.min(Math.max(Math.trunc(props.limit), 1), 100); + return { + filter: props.filter?.trim(), + limit, + page: props.page ?? 1, + order: props.order?.trim(), + }; + }, + nqlString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + }, + id(value: string, label: string): string { + const text = (value ?? '').trim(); + if (!text) { + throw new Error(`${label} is required.`); + } + return encodeURIComponent(text); + }, + stringList(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + const items = Array.isArray(value) ? value : [value]; + const list = items + .map((item) => { + if (item !== null && typeof item === 'object') { + const first = Object.values(item).find((entry) => typeof entry === 'string'); + return typeof first === 'string' ? first : ''; + } + return String(item ?? ''); + }) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return list.length > 0 ? list : undefined; + }, + triState(value: unknown): boolean | undefined { + if (value === true || value === 'true') { + return true; + } + if (value === false || value === 'false') { + return false; + } + return undefined; + }, + hasText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; + }, + first(items: T[] | undefined, resource: string): T { + if (!items || items.length === 0) { + throw new Error(`Ghost returned no ${resource}.`); + } + return items[0]; + }, + futureDate(value: unknown, label: string): string { + const date = new Date(String(value ?? '')); + if (Number.isNaN(date.getTime())) { + throw new Error(`${label} must be a valid date and time.`); + } + if (date.getTime() <= Date.now()) { + throw new Error(`${label} must be in the future.`); + } + return date.toISOString(); + }, +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/content.ts b/packages/pieces/community/ghostcms/src/lib/common/content.ts new file mode 100644 index 000000000000..2f3cd7a6a77e --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/content.ts @@ -0,0 +1,212 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import { GhostAuthValue, ghostClient, ghostCommon } from './client'; +import { ghostProps } from './ai-props'; +import { applyClearFields, clearFieldsProp } from './clear-fields'; + +export type GhostContentResource = 'posts' | 'pages'; + +type ContentItem = Record & { id: string; updated_at: string; status?: string }; + +const CONTENT_QUERY = { formats: 'html', include: 'tags,authors' }; + +const visibility = Property.StaticDropdown({ + displayName: 'Visibility', + description: 'Who can read the content on the site.', + required: false, + options: { + options: [ + { label: 'Public', value: 'public' }, + { label: 'Members only', value: 'members' }, + { label: 'Paid members only', value: 'paid' }, + ], + }, +}); + +export const contentProps = ({ label, mode }: { label: 'post' | 'page'; mode: 'create' | 'update' }) => ({ + title: Property.ShortText({ + displayName: 'Title', + description: `The ${label} title.`, + required: mode === 'create', + }), + html: Property.LongText({ + displayName: 'Content (HTML)', + description: `The ${label} body as HTML. Ghost converts it to its editor format.${ + mode === 'update' ? ' Supplying it replaces the whole body.' : '' + }`, + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: `The URL slug of the ${label}. Ghost generates one from the title when empty.`, + required: false, + }), + custom_excerpt: Property.LongText({ + displayName: 'Excerpt', + description: 'A short summary shown in lists and previews.', + required: false, + }), + feature_image: Property.ShortText({ + displayName: 'Feature Image URL', + description: 'The URL of the feature image, for example one returned by Upload Image.', + required: false, + }), + featured: ghostProps.triState('Featured', `Mark the ${label} as featured.`), + visibility, + tags: Property.Array({ + displayName: 'Tags', + description: `Tag names. Missing tags are created.${ + mode === 'update' ? ' Supplying tags replaces the full tag list; an empty list removes every tag, and leaving it out keeps the current tags.' : '' + }`, + required: false, + }), + authors: Property.Array({ + displayName: 'Authors', + description: `Staff user IDs or emails, from List Users.${ + mode === 'update' + ? ' Supplying authors replaces the full author list. Ghost needs at least one author, so an empty list is rejected.' + : ' Defaults to the integration owner.' + }`, + required: false, + }), + meta_title: Property.ShortText({ + displayName: 'Meta Title', + description: 'The SEO title.', + required: false, + }), + meta_description: Property.LongText({ + displayName: 'Meta Description', + description: 'The SEO description.', + required: false, + }), + ...(mode === 'update' ? { clear_fields: clearFieldsProp(CLEARABLE_CONTENT_FIELDS) } : {}), +}); + +const CLEARABLE_CONTENT_FIELDS = [ + { label: 'Excerpt', value: 'custom_excerpt' }, + { label: 'Feature Image URL', value: 'feature_image' }, + { label: 'Meta Title', value: 'meta_title' }, + { label: 'Meta Description', value: 'meta_description' }, +]; + +export const ghostContent = { + strip>(item: T): T { + const copy = { ...item }; + delete copy['lexical']; + delete copy['mobiledoc']; + return copy; + }, + body(props: Record): Record { + const body: Record = {}; + for (const key of ['title', 'html', 'slug', 'custom_excerpt', 'feature_image', 'meta_title', 'meta_description']) { + const value = props[key]; + if (typeof value === 'string' && value.trim().length > 0) { + body[key] = key === 'html' ? value : value.trim(); + } + } + const featured = ghostCommon.triState(props['featured']); + if (featured !== undefined) { + body['featured'] = featured; + } + if (ghostCommon.hasText(props['visibility'])) { + body['visibility'] = props['visibility']; + } + const rawTags = props['tags']; + const tags = ghostCommon.stringList(rawTags); + if (tags) { + body['tags'] = tags.map((name) => ({ name })); + } else if (Array.isArray(rawTags)) { + body['tags'] = []; + } + const rawAuthors = props['authors']; + const authors = ghostCommon.stringList(rawAuthors); + if (authors) { + body['authors'] = authors.map((value) => (value.includes('@') ? { email: value } : { id: value })); + } else if (Array.isArray(rawAuthors)) { + throw new Error('Ghost requires at least one author. Leave Authors out to keep the current authors, or pass the staff user IDs or emails you want.'); + } + return applyClearFields({ + body, + clear: props['clear_fields'], + allowed: CLEARABLE_CONTENT_FIELDS.map((field) => field.value), + clearValue: null, + }); + }, + async list( + auth: GhostAuthValue, + resource: GhostContentResource, + props: { filter?: string | null; limit?: number | null; page?: number | null; order?: string | null; include_content?: boolean | null } + ) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.GET, + path: `/${resource}`, + query: { + ...ghostCommon.listQuery(props), + include: 'tags,authors', + formats: props.include_content ? 'html' : undefined, + }, + }); + const items = ghostCommon.records(response[resource]).map((item) => { + const stripped = ghostContent.strip(item); + if (!props.include_content) { + delete stripped['html']; + } + return stripped; + }); + return { [resource]: items, ...ghostCommon.pagination(response['meta']) }; + }, + async get(auth: GhostAuthValue, resource: GhostContentResource, path: string): Promise { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.GET, + path: `/${resource}/${path}`, + query: CONTENT_QUERY, + }); + return ghostContent.strip(ghostCommon.first(response[resource], resource)); + }, + async create(auth: GhostAuthValue, resource: GhostContentResource, body: Record) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.POST, + path: `/${resource}`, + query: { ...CONTENT_QUERY, source: body['html'] !== undefined ? 'html' : undefined }, + body: { [resource]: [body] }, + }); + return ghostContent.strip(ghostCommon.first(response[resource], resource)); + }, + async edit( + auth: GhostAuthValue, + resource: GhostContentResource, + id: string, + changes: Record | ((current: ContentItem) => Record), + query?: Record + ) { + const current = await ghostContent.get(auth, resource, id); + const body = typeof changes === 'function' ? changes(current) : changes; + const response = await ghostClient.request>({ + auth, + method: HttpMethod.PUT, + path: `/${resource}/${id}`, + query: { ...CONTENT_QUERY, source: body['html'] !== undefined ? 'html' : undefined, ...(query ?? {}) }, + body: { [resource]: [{ ...body, updated_at: current.updated_at }] }, + }); + return ghostContent.strip(ghostCommon.first(response[resource], resource)); + }, + async copy(auth: GhostAuthValue, resource: GhostContentResource, id: string) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.POST, + path: `/${resource}/${id}/copy`, + query: CONTENT_QUERY, + }); + return ghostContent.strip(ghostCommon.first(response[resource], resource)); + }, + async remove(auth: GhostAuthValue, resource: GhostContentResource, id: string) { + await ghostClient.request({ + auth, + method: HttpMethod.DELETE, + path: `/${resource}/${id}`, + }); + }, +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/custom-api-guard.ts b/packages/pieces/community/ghostcms/src/lib/common/custom-api-guard.ts new file mode 100644 index 000000000000..c75adac3bc90 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/custom-api-guard.ts @@ -0,0 +1,35 @@ +import { GhostAuthValue, ghostClient, ghostCommon } from './client'; + +const ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i; + +export const assertGhostUrl = ({ auth, propsValue }: { auth: GhostAuthValue; propsValue: unknown }): void => { + const urlProp = ghostCommon.isRecord(propsValue) ? propsValue['url'] : undefined; + const url = ghostCommon.isRecord(urlProp) ? urlProp['url'] : undefined; + if (typeof url !== 'string') { + return; + } + const target = url.trim(); + const base = ghostClient.adminUrl(auth); + const refuse = (): never => { + throw new Error( + `Custom API Call only sends your Ghost Admin API key to ${base}. Use a path relative to it, e.g. /posts/.` + ); + }; + if (ABSOLUTE.test(target) || target.startsWith('//') || target.startsWith('\\')) { + if (!(target === base || target.startsWith(`${base}/`) || target.startsWith(`${base}?`))) { + refuse(); + } + } + const path = target.split(/[?#]/)[0]; + if (path.split(/[/\\]/).some((segment) => decodeSegment(segment) === '..')) { + refuse(); + } +}; + +const decodeSegment = (segment: string): string => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/index.ts b/packages/pieces/community/ghostcms/src/lib/common/index.ts index e730741dc244..f09785f59370 100644 --- a/packages/pieces/community/ghostcms/src/lib/common/index.ts +++ b/packages/pieces/community/ghostcms/src/lib/common/index.ts @@ -2,7 +2,7 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; import { Property } from '@activepieces/pieces-framework'; import jwt from 'jsonwebtoken'; -import { ghostAuth } from '../..'; +import { ghostAuth } from '../auth'; export const common = { properties: { diff --git a/packages/pieces/community/ghostcms/src/lib/common/member-labels.ts b/packages/pieces/community/ghostcms/src/lib/common/member-labels.ts new file mode 100644 index 000000000000..e583539e37dd --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/member-labels.ts @@ -0,0 +1,39 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { GhostAuthValue, ghostClient, ghostCommon } from './client'; +import { ghostResource } from './resources'; + +// Ghost wraps bulk stats in `bulk.meta` (core/server/api/endpoints/utils/serializers/output/members.js bulkAction). +type BulkResponse = { + bulk?: { meta?: { stats?: { successful?: number; unsuccessful?: number }; errors?: unknown[] } }; +}; + +export const changeMemberLabel = async ({ + auth, + memberId, + labelId, + action, +}: { + auth: GhostAuthValue; + memberId: string; + labelId: string; + action: 'addLabel' | 'removeLabel'; +}) => { + const id = ghostCommon.id(memberId, 'Member ID'); + const label = (labelId ?? '').trim(); + if (!label) { + throw new Error('Label ID is required.'); + } + await ghostResource.get(auth, 'members', id); + const response = await ghostClient.request({ + auth, + method: HttpMethod.PUT, + path: '/members/bulk', + query: { filter: `id:${ghostCommon.nqlString(memberId.trim())}` }, + body: { bulk: { action, meta: { label: { id: label } } } }, + }); + const unsuccessful = response.bulk?.meta?.stats?.unsuccessful ?? 0; + if (unsuccessful > 0) { + throw new Error(`Ghost could not update the member label: ${JSON.stringify(response.bulk?.meta?.errors ?? [])}`); + } + return ghostResource.get(auth, 'members', id); +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/member-newsletters.ts b/packages/pieces/community/ghostcms/src/lib/common/member-newsletters.ts new file mode 100644 index 000000000000..99fbbe8e28f0 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/member-newsletters.ts @@ -0,0 +1,50 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { GhostAuthValue, ghostClient, ghostCommon } from './client'; +import { ghostResource } from './resources'; + +export const currentNewsletterIds = async ({ auth, memberId }: { auth: GhostAuthValue; memberId: string }) => { + const member = await ghostResource.get(auth, 'members', memberId); + return ghostCommon + .records(member['newsletters']) + .map((newsletter) => newsletter['id']) + .filter((id): id is string => typeof id === 'string'); +}; + +export const saveNewsletters = async ({ auth, memberId, ids }: { auth: GhostAuthValue; memberId: string; ids: string[] }) => + ghostResource.edit(auth, 'members', memberId, { + newsletters: ids.map((id) => ({ id })), + }); + +export const requireIds = (value: unknown): string[] => { + const ids = ghostCommon.stringList(value); + if (!ids) { + throw new Error('Provide at least one newsletter ID, from List Newsletters.'); + } + return ids; +}; + +// Ghost wraps bulk stats in `bulk.meta` (core/server/api/endpoints/utils/serializers/output/members.js bulkAction). +type BulkUnsubscribeResponse = { + bulk?: { meta?: { stats?: { unsuccessful?: number }; errors?: unknown[] } }; +}; + +export const unsubscribeFromNewsletter = async ({ + auth, + memberId, + newsletterId, +}: { + auth: GhostAuthValue; + memberId: string; + newsletterId: string; +}) => { + const response = await ghostClient.request({ + auth, + method: HttpMethod.PUT, + path: '/members/bulk', + query: { filter: `id:${ghostCommon.nqlString(memberId)}` }, + body: { bulk: { action: 'unsubscribe', newsletter: newsletterId } }, + }); + if ((response.bulk?.meta?.stats?.unsuccessful ?? 0) > 0) { + throw new Error(`Ghost could not remove the newsletter subscription: ${JSON.stringify(response.bulk?.meta?.errors ?? [])}`); + } +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/newsletter-props.ts b/packages/pieces/community/ghostcms/src/lib/common/newsletter-props.ts new file mode 100644 index 000000000000..c4b26e0e06be --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/newsletter-props.ts @@ -0,0 +1,48 @@ +import { Property } from '@activepieces/pieces-framework'; +import { ghostProps } from './ai-props'; + +export const NEWSLETTER_FIELDS = ['name', 'description', 'sender_name', 'sender_reply_to', 'visibility']; + +export const newsletterProps = (mode: 'create' | 'update') => ({ + name: Property.ShortText({ + displayName: 'Name', + description: 'The newsletter name.', + required: mode === 'create', + }), + description: Property.LongText({ + displayName: 'Description', + description: 'A description shown to members when they choose newsletters.', + required: false, + }), + sender_name: Property.ShortText({ + displayName: 'Sender Name', + description: 'The From name of the emails. Defaults to the site title.', + required: false, + }), + sender_reply_to: Property.StaticDropdown({ + displayName: 'Reply-To', + description: 'Where member replies go.', + required: false, + options: { + options: [ + { label: 'Newsletter sender address', value: 'newsletter' }, + { label: 'Support address', value: 'support' }, + ], + }, + }), + visibility: Property.StaticDropdown({ + displayName: 'Audience', + description: 'Who can subscribe to the newsletter.', + required: false, + options: { + options: [ + { label: 'All members', value: 'members' }, + { label: 'Paid members only', value: 'paid' }, + ], + }, + }), + subscribe_on_signup: ghostProps.triState( + 'Subscribe on Signup', + 'Automatically subscribe new members to this newsletter.' + ), +}); diff --git a/packages/pieces/community/ghostcms/src/lib/common/resources.ts b/packages/pieces/community/ghostcms/src/lib/common/resources.ts new file mode 100644 index 000000000000..bb5b4fc6fd99 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/resources.ts @@ -0,0 +1,90 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { GhostAuthValue, ghostClient, ghostCommon } from './client'; + +type QueryValue = string | number | boolean | undefined | null; +type Item = Record; + +export const ghostResource = { + async list( + auth: GhostAuthValue, + resource: string, + query: Record, + paginated = true + ) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.GET, + path: `/${resource}`, + query, + }); + const items = ghostCommon.records(response[resource]); + return paginated + ? { [resource]: items, ...ghostCommon.pagination(response['meta']) } + : { [resource]: items, count: items.length }; + }, + async get(auth: GhostAuthValue, resource: string, path: string, query?: Record) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.GET, + path: `/${resource}/${path}`, + query, + }); + return ghostCommon.first(response[resource], resource); + }, + async create( + auth: GhostAuthValue, + resource: string, + body: Item, + query?: Record + ) { + const response = await ghostClient.request>({ + auth, + method: HttpMethod.POST, + path: `/${resource}`, + query, + body: { [resource]: [body] }, + }); + return ghostCommon.first(response[resource], resource); + }, + async edit( + auth: GhostAuthValue, + resource: string, + id: string, + body: Item, + query?: Record + ) { + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to change.'); + } + const response = await ghostClient.request>({ + auth, + method: HttpMethod.PUT, + path: `/${resource}/${id}`, + query, + body: { [resource]: [body] }, + }); + return ghostCommon.first(response[resource], resource); + }, + async remove(auth: GhostAuthValue, resource: string, id: string, query?: Record) { + await ghostClient.request({ + auth, + method: HttpMethod.DELETE, + path: `/${resource}/${id}`, + query, + }); + }, + pick(props: Record, keys: string[]): Item { + const body: Item = {}; + for (const key of keys) { + const value = props[key]; + if (typeof value === 'string') { + if (value.trim().length > 0) { + body[key] = value.trim(); + } + } else if (value !== undefined && value !== null) { + body[key] = value; + } + } + return body; + }, +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/tag-props.ts b/packages/pieces/community/ghostcms/src/lib/common/tag-props.ts new file mode 100644 index 000000000000..9ba4aff53897 --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/tag-props.ts @@ -0,0 +1,36 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const TAG_FIELDS = ['name', 'slug', 'description', 'accent_color', 'meta_title', 'meta_description']; + +export const tagProps = (mode: 'create' | 'update') => ({ + name: Property.ShortText({ + displayName: 'Name', + description: 'The tag name. Start it with # to make an internal tag that is hidden from readers.', + required: mode === 'create', + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'The URL slug. Ghost generates one from the name when empty.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'The tag description shown on its archive page.', + required: false, + }), + accent_color: Property.ShortText({ + displayName: 'Accent Color', + description: 'A hex color, e.g. #FF5733.', + required: false, + }), + meta_title: Property.ShortText({ + displayName: 'Meta Title', + description: 'The SEO title of the tag page.', + required: false, + }), + meta_description: Property.LongText({ + displayName: 'Meta Description', + description: 'The SEO description of the tag page.', + required: false, + }), +}); diff --git a/packages/pieces/community/ghostcms/src/lib/common/tier-body.ts b/packages/pieces/community/ghostcms/src/lib/common/tier-body.ts new file mode 100644 index 000000000000..5de43866fb8d --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/tier-body.ts @@ -0,0 +1,24 @@ +import { ghostCommon } from './client'; +import { ghostResource } from './resources'; +import { TIER_FIELDS } from './tier-props'; + +export const tierBody = (props: Record) => { + const body = ghostResource.pick(props, TIER_FIELDS); + for (const key of ['monthly_price', 'yearly_price', 'trial_days']) { + if (body[key] !== undefined) { + const value = Number(body[key]); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${key} must be a whole number of 0 or more.`); + } + body[key] = value; + } + } + const currency = body['currency']; + if (typeof currency === 'string') { + body['currency'] = currency.toLowerCase(); + } + if (Array.isArray(props['benefits'])) { + body['benefits'] = ghostCommon.stringList(props['benefits']) ?? []; + } + return body; +}; diff --git a/packages/pieces/community/ghostcms/src/lib/common/tier-props.ts b/packages/pieces/community/ghostcms/src/lib/common/tier-props.ts new file mode 100644 index 000000000000..30fce98d433c --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/tier-props.ts @@ -0,0 +1,70 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const TIER_QUERY = { include: 'monthly_price,yearly_price,benefits' }; + +export const TIER_FIELDS = [ + 'name', + 'description', + 'monthly_price', + 'yearly_price', + 'currency', + 'visibility', + 'welcome_page_url', + 'trial_days', +]; + +export const tierProps = (mode: 'create' | 'update') => ({ + name: Property.ShortText({ + displayName: 'Name', + description: 'The tier name shown on the pricing page.', + required: mode === 'create', + }), + description: Property.LongText({ + displayName: 'Description', + description: 'A short description shown on the pricing page.', + required: false, + }), + monthly_price: Property.Number({ + displayName: 'Monthly Price', + description: 'The monthly price in the smallest currency unit, e.g. 500 for 5.00 USD.', + required: false, + }), + yearly_price: Property.Number({ + displayName: 'Yearly Price', + description: 'The yearly price in the smallest currency unit, e.g. 5000 for 50.00 USD.', + required: false, + }), + currency: Property.ShortText({ + displayName: 'Currency', + description: 'The three-letter currency code, e.g. usd. Required with a price.', + required: false, + }), + benefits: Property.Array({ + displayName: 'Benefits', + description: `The benefit lines shown on the pricing page.${ + mode === 'update' ? ' Supplying benefits replaces the full list, and an empty list removes every benefit.' : '' + }`, + required: false, + }), + visibility: Property.StaticDropdown({ + displayName: 'Visibility', + description: 'Whether the tier is shown on the public pricing page.', + required: false, + options: { + options: [ + { label: 'Public', value: 'public' }, + { label: 'Hidden', value: 'none' }, + ], + }, + }), + welcome_page_url: Property.ShortText({ + displayName: 'Welcome Page URL', + description: 'Where new subscribers of this tier land after signup.', + required: false, + }), + trial_days: Property.Number({ + displayName: 'Trial Days', + description: 'The free trial length in days. 0 for no trial.', + required: false, + }), +}); diff --git a/packages/pieces/community/ghostcms/src/lib/common/webhooks.ts b/packages/pieces/community/ghostcms/src/lib/common/webhooks.ts new file mode 100644 index 000000000000..d73a4fd87fad --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/common/webhooks.ts @@ -0,0 +1,150 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import crypto from 'crypto'; +import { GhostApiError, GhostAuthValue, ghostClient, ghostCommon } from './client'; + +type WebhookStore = { + put(key: string, value: StoredWebhook): Promise; + get(key: string): Promise; + delete(key: string): Promise; +}; + +type StoredWebhook = { webhookId: string; secret?: string }; + +const SIGNATURE_HEADER = 'x-ghost-signature'; + +const storedWebhookOf = (value: unknown): StoredWebhook | null => { + if (!ghostCommon.isRecord(value) || typeof value['webhookId'] !== 'string') { + return null; + } + return { + webhookId: value['webhookId'], + secret: typeof value['secret'] === 'string' ? value['secret'] : undefined, + }; +}; + +const deleteWebhook = async ({ auth, webhookId }: { auth: GhostAuthValue; webhookId: string }) => { + try { + await ghostClient.request({ + auth, + method: HttpMethod.DELETE, + path: `/webhooks/${encodeURIComponent(webhookId)}`, + }); + } catch (error) { + if (error instanceof GhostApiError && error.status === 404) { + return; + } + throw error; + } +}; + +const signatureHeaderOf = (headers: unknown): string | undefined => { + if (!ghostCommon.isRecord(headers)) { + return undefined; + } + const name = Object.keys(headers).find((key) => key.toLowerCase() === SIGNATURE_HEADER); + const value = name ? headers[name] : undefined; + return typeof value === 'string' ? value : undefined; +}; + +const signatureMatches = ({ + secret, + rawBody, + signatureHeader, +}: { + secret: string; + rawBody: unknown; + signatureHeader: string | undefined; +}): boolean => { + if (!signatureHeader || !(typeof rawBody === 'string' || Buffer.isBuffer(rawBody))) { + return false; + } + const parts: Record = {}; + for (const part of signatureHeader.split(',')) { + const [key, ...rest] = part.trim().split('='); + if (key && rest.length > 0) { + parts[key] = rest.join('='); + } + } + const provided = parts['sha256']; + const timestamp = parts['t']; + if (!provided || !timestamp) { + return false; + } + const expected = crypto + .createHmac('sha256', secret) + .update(Buffer.concat([Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody), Buffer.from(timestamp)])) + .digest(); + const providedBytes = Buffer.from(provided, 'hex'); + return providedBytes.length === expected.length && crypto.timingSafeEqual(providedBytes, expected); +}; + +export const ghostWebhook = { + async enable({ + auth, + event, + webhookUrl, + store, + storeKey, + }: { + auth: GhostAuthValue; + event: string; + webhookUrl: string; + store: WebhookStore; + storeKey: string; + }): Promise { + const secret = crypto.randomBytes(32).toString('hex'); + const response = await ghostClient.request<{ webhooks?: { id?: string }[] }>({ + auth, + method: HttpMethod.POST, + path: '/webhooks', + body: { webhooks: [{ event, target_url: webhookUrl, secret }] }, + }); + const webhookId = response.webhooks?.[0]?.id; + if (!webhookId) { + throw new Error(`Ghost did not return an ID for the ${event} webhook.`); + } + try { + await store.put(storeKey, { webhookId, secret }); + } catch (error) { + await deleteWebhook({ auth, webhookId }); + throw error; + } + }, + async disable({ + auth, + store, + storeKey, + }: { + auth: GhostAuthValue; + store: WebhookStore; + storeKey: string; + }): Promise { + const stored = storedWebhookOf(await store.get(storeKey)); + if (!stored) { + return; + } + await deleteWebhook({ auth, webhookId: stored.webhookId }); + await store.delete(storeKey); + }, + async assertSigned({ + store, + storeKey, + rawBody, + headers, + }: { + store: WebhookStore; + storeKey: string; + rawBody: unknown; + headers: unknown; + }): Promise { + const stored = storedWebhookOf(await store.get(storeKey)); + if (!stored?.secret) { + return; + } + if (!signatureMatches({ secret: stored.secret, rawBody, signatureHeader: signatureHeaderOf(headers) })) { + throw new Error( + 'The X-Ghost-Signature header did not match the secret this trigger registered with Ghost, so the request was not accepted.' + ); + } + }, +}; diff --git a/packages/pieces/community/ghostcms/src/lib/output-schemas.ts b/packages/pieces/community/ghostcms/src/lib/output-schemas.ts new file mode 100644 index 000000000000..88133de6b49d --- /dev/null +++ b/packages/pieces/community/ghostcms/src/lib/output-schemas.ts @@ -0,0 +1,619 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +type Fields = OutputSchema['fields']; + +const paginationFields: Fields = [ + { key: 'page', label: 'Page', format: 'number' }, + { key: 'limit', label: 'Limit' }, + { key: 'total_pages', label: 'Total Pages', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + { key: 'next', label: 'Next Page', format: 'number' }, + { key: 'prev', label: 'Previous Page', format: 'number' }, +]; + +const tagFields: Fields = [ + { key: 'id', label: 'Tag ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'accent_color', label: 'Accent Color' }, + { key: 'feature_image', label: 'Feature Image', format: 'image' }, + { key: 'meta_title', label: 'Meta Title' }, + { key: 'meta_description', label: 'Meta Description' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const tagWithCountFields: Fields = [ + ...tagFields, + { + key: 'count', + label: 'Count', + children: [{ key: 'posts', label: 'Posts', format: 'number' }], + }, +]; + +const authorFields: Fields = [ + { key: 'id', label: 'User ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'status', label: 'Status' }, + { key: 'profile_image', label: 'Profile Image', format: 'image' }, + { key: 'bio', label: 'Bio' }, + { key: 'website', label: 'Website', format: 'url' }, + { key: 'location', label: 'Location' }, + { key: 'url', label: 'Author Page URL', format: 'url' }, +]; + +const userBaseFields: Fields = [ + ...authorFields, + { key: 'cover_image', label: 'Cover Image', format: 'image' }, + { key: 'last_seen', label: 'Last Seen', format: 'datetime' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const userFields: Fields = [ + ...userBaseFields, + { + key: 'roles', + label: 'Roles', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Role ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + ], + }, + { + key: 'count', + label: 'Count', + children: [{ key: 'posts', label: 'Posts', format: 'number' }], + }, +]; + +const contentBaseFields: Fields = [ + { key: 'id', label: 'ID' }, + { key: 'uuid', label: 'UUID' }, + { key: 'title', label: 'Title' }, + { key: 'slug', label: 'Slug' }, + { key: 'status', label: 'Status' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'html', label: 'HTML', format: 'html' }, + { key: 'excerpt', label: 'Excerpt' }, + { key: 'custom_excerpt', label: 'Custom Excerpt' }, + { key: 'featured', label: 'Featured', format: 'boolean' }, + { key: 'feature_image', label: 'Feature Image', format: 'image' }, + { key: 'feature_image_alt', label: 'Feature Image Alt Text' }, + { key: 'feature_image_caption', label: 'Feature Image Caption' }, + { key: 'reading_time', label: 'Reading Time (minutes)', format: 'number' }, + { key: 'meta_title', label: 'Meta Title' }, + { key: 'meta_description', label: 'Meta Description' }, + { key: 'canonical_url', label: 'Canonical URL', format: 'url' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'published_at', label: 'Published At', format: 'datetime' }, + { key: 'tags', label: 'Tags', labelKey: 'name', listItems: tagFields }, + { key: 'authors', label: 'Authors', labelKey: 'name', listItems: authorFields }, + { key: 'primary_tag', label: 'Primary Tag', children: tagFields }, + { key: 'primary_author', label: 'Primary Author', children: authorFields }, +]; + +const postFields: Fields = [ + ...contentBaseFields, + { key: 'email_only', label: 'Email Only', format: 'boolean' }, + { key: 'email_segment', label: 'Email Segment' }, + { key: 'email_subject', label: 'Email Subject' }, +]; + +const pageFields: Fields = [ + ...contentBaseFields, + { + key: 'show_title_and_feature_image', + label: 'Show Title and Feature Image', + format: 'boolean', + }, +]; + +const labelFields: Fields = [ + { key: 'id', label: 'Label ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const labelWithCountFields: Fields = [ + ...labelFields, + { + key: 'count', + label: 'Count', + children: [{ key: 'members', label: 'Members', format: 'number' }], + }, +]; + +const memberNewsletterFields: Fields = [ + { key: 'id', label: 'Newsletter ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'status', label: 'Status' }, +]; + +const memberCoreFields: Fields = [ + { key: 'id', label: 'Member ID' }, + { key: 'uuid', label: 'UUID' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'name', label: 'Name' }, + { key: 'note', label: 'Note' }, + { key: 'status', label: 'Status' }, + { key: 'geolocation', label: 'Geolocation' }, + { key: 'email_count', label: 'Emails Received', format: 'number' }, + { key: 'email_opened_count', label: 'Emails Opened', format: 'number' }, + { key: 'email_open_rate', label: 'Email Open Rate', format: 'number' }, + { key: 'last_seen_at', label: 'Last Seen At', format: 'datetime' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'labels', label: 'Labels', labelKey: 'name', listItems: labelFields }, + { + key: 'newsletters', + label: 'Newsletters', + labelKey: 'name', + listItems: memberNewsletterFields, + }, +]; + +const memberListFields: Fields = [ + ...memberCoreFields, + { key: 'subscribed', label: 'Subscribed', format: 'boolean' }, + { key: 'comped', label: 'Comped', format: 'boolean' }, + { key: 'avatar_image', label: 'Avatar Image', format: 'image' }, +]; + +const memberFields: Fields = [...memberListFields, { key: 'tiers', label: 'Tiers' }]; + +const memberWithAttributionFields: Fields = [ + ...memberFields, + { key: 'unsubscribe_url', label: 'Unsubscribe URL', format: 'url' }, + { + key: 'attribution', + label: 'Attribution', + children: [ + { key: 'referrer_source', label: 'Referrer Source' }, + { key: 'referrer_medium', label: 'Referrer Medium' }, + { key: 'referrer_url', label: 'Referrer URL', format: 'url' }, + { key: 'url', label: 'Signup Page URL', format: 'url' }, + { key: 'title', label: 'Signup Page Title' }, + ], + }, +]; + +const newsletterFields: Fields = [ + { key: 'id', label: 'Newsletter ID' }, + { key: 'uuid', label: 'UUID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'status', label: 'Status' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'subscribe_on_signup', label: 'Subscribe on Signup', format: 'boolean' }, + { key: 'sender_name', label: 'Sender Name' }, + { key: 'sender_email', label: 'Sender Email', format: 'email' }, + { key: 'sender_reply_to', label: 'Reply To' }, + { key: 'sort_order', label: 'Sort Order', format: 'number' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const tierFields: Fields = [ + { key: 'id', label: 'Tier ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'type', label: 'Type' }, + { key: 'active', label: 'Active', format: 'boolean' }, + { key: 'visibility', label: 'Visibility' }, + { key: 'welcome_page_url', label: 'Welcome Page URL', format: 'url' }, + { key: 'trial_days', label: 'Trial Days', format: 'number' }, + { key: 'benefits', label: 'Benefits' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, +]; + +const tierWithPriceFields: Fields = [ + ...tierFields, + { key: 'currency', label: 'Currency' }, + { key: 'monthly_price', label: 'Monthly Price (cents)', format: 'number' }, + { key: 'yearly_price', label: 'Yearly Price (cents)', format: 'number' }, +]; + +const offerFields: Fields = [ + { key: 'id', label: 'Offer ID' }, + { key: 'name', label: 'Name' }, + { key: 'code', label: 'Code' }, + { key: 'display_title', label: 'Display Title' }, + { key: 'display_description', label: 'Display Description' }, + { key: 'type', label: 'Discount Type' }, + { key: 'amount', label: 'Amount', format: 'number' }, + { key: 'currency', label: 'Currency' }, + { key: 'cadence', label: 'Cadence' }, + { key: 'duration', label: 'Duration' }, + { key: 'duration_in_months', label: 'Duration in Months', format: 'number' }, + { key: 'status', label: 'Status' }, + { key: 'redemption_count', label: 'Redemptions', format: 'number' }, + { key: 'redemption_type', label: 'Redemption Type' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'last_redeemed', label: 'Last Redeemed', format: 'datetime' }, +]; + +const offerWithTierFields: Fields = [ + ...offerFields, + { + key: 'tier', + label: 'Tier', + children: [ + { key: 'id', label: 'Tier ID' }, + { key: 'name', label: 'Name' }, + ], + }, +]; + +const createdOfferFields: Fields = [ + ...offerFields, + { key: 'tier', label: 'Tier', children: [{ key: 'id', label: 'Tier ID' }] }, +]; + +const emailFields: Fields = [ + { key: 'id', label: 'Email ID' }, + { key: 'status', label: 'Status' }, + { key: 'subject', label: 'Subject' }, + { key: 'recipient_filter', label: 'Recipient Filter' }, + { key: 'email_count', label: 'Recipients', format: 'number' }, + { key: 'delivered_count', label: 'Delivered', format: 'number' }, + { key: 'opened_count', label: 'Opened', format: 'number' }, + { key: 'failed_count', label: 'Failed', format: 'number' }, + { key: 'submitted_at', label: 'Submitted At', format: 'datetime' }, + { key: 'newsletter_id', label: 'Newsletter ID' }, + { key: 'error', label: 'Error' }, +]; + +const publishedPostFields: Fields = [ + ...postFields, + { key: 'email', label: 'Email', children: emailFields }, + { + key: 'newsletter', + label: 'Newsletter', + children: [ + { key: 'id', label: 'Newsletter ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'status', label: 'Status' }, + ], + }, +]; + +const listSchema = ({ + key, + label, + labelKey, + items, +}: { + key: string; + label: string; + labelKey: string; + items: Fields; +}): OutputSchema => ({ + fields: [{ key, label, labelKey, listItems: items }, ...paginationFields], +}); + +const deleteSchema = ({ idKey, idLabel }: { idKey: string; idLabel: string }): OutputSchema => ({ + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: idKey, label: idLabel }, + ], +}); + +export const ghostPostOutputSchema: OutputSchema = { fields: postFields }; +export const ghostPublishPostOutputSchema: OutputSchema = { fields: publishedPostFields }; +export const ghostListPostsOutputSchema = listSchema({ key: 'posts', label: 'Posts', labelKey: 'title', items: postFields }); +export const ghostDeletePostOutputSchema = deleteSchema({ idKey: 'post_id', idLabel: 'Post ID' }); + +export const ghostPageOutputSchema: OutputSchema = { fields: pageFields }; +export const ghostListPagesOutputSchema = listSchema({ key: 'pages', label: 'Pages', labelKey: 'title', items: pageFields }); +export const ghostDeletePageOutputSchema = deleteSchema({ idKey: 'page_id', idLabel: 'Page ID' }); + +export const ghostTagOutputSchema: OutputSchema = { fields: tagWithCountFields }; +export const ghostCreateTagOutputSchema: OutputSchema = { fields: tagFields }; +export const ghostListTagsOutputSchema = listSchema({ key: 'tags', label: 'Tags', labelKey: 'name', items: tagWithCountFields }); +export const ghostDeleteTagOutputSchema = deleteSchema({ idKey: 'tag_id', idLabel: 'Tag ID' }); + +export const ghostMemberOutputSchema: OutputSchema = { fields: memberWithAttributionFields }; +export const ghostListMembersOutputSchema = listSchema({ key: 'members', label: 'Members', labelKey: 'email', items: memberListFields }); +export const ghostGetMemberByEmailOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'member', label: 'Member', children: memberListFields }, + ], +}; +export const ghostDeleteMemberOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'member_id', label: 'Member ID' }, + { + key: 'stripe_subscriptions_cancelled', + label: 'Stripe Subscriptions Cancelled', + format: 'boolean', + }, + ], +}; + +export const ghostLabelOutputSchema: OutputSchema = { fields: labelWithCountFields }; +export const ghostCreateLabelOutputSchema: OutputSchema = { fields: labelFields }; +export const ghostListLabelsOutputSchema = listSchema({ key: 'labels', label: 'Labels', labelKey: 'name', items: labelWithCountFields }); +export const ghostDeleteLabelOutputSchema = deleteSchema({ idKey: 'label_id', idLabel: 'Label ID' }); + +export const ghostNewsletterOutputSchema: OutputSchema = { fields: newsletterFields }; +export const ghostListNewslettersOutputSchema = listSchema({ key: 'newsletters', label: 'Newsletters', labelKey: 'name', items: newsletterFields }); + +export const ghostTierOutputSchema: OutputSchema = { fields: tierWithPriceFields }; +export const ghostListTiersOutputSchema = listSchema({ key: 'tiers', label: 'Tiers', labelKey: 'name', items: tierWithPriceFields }); + +export const ghostOfferOutputSchema: OutputSchema = { fields: offerWithTierFields }; +export const ghostCreateOfferOutputSchema: OutputSchema = { fields: createdOfferFields }; +export const ghostListOffersOutputSchema: OutputSchema = { + fields: [ + { key: 'offers', label: 'Offers', labelKey: 'name', listItems: offerWithTierFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const ghostUserOutputSchema: OutputSchema = { fields: userFields }; +export const ghostListUsersOutputSchema = listSchema({ key: 'users', label: 'Users', labelKey: 'name', items: userFields }); +export const ghostGetUserByEmailOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'user', label: 'User', children: userFields }, + ], +}; + +export const ghostUploadImageOutputSchema: OutputSchema = { + fields: [ + { key: 'url', label: 'Image URL', format: 'image' }, + { key: 'ref', label: 'Reference' }, + ], +}; +export const ghostUploadFileOutputSchema: OutputSchema = { + fields: [ + { key: 'url', label: 'File URL', format: 'url' }, + { key: 'ref', label: 'Reference' }, + ], +}; +export const ghostUploadMediaOutputSchema: OutputSchema = { + fields: [ + { key: 'url', label: 'Media URL', format: 'url' }, + { key: 'thumbnail_url', label: 'Thumbnail URL', format: 'image' }, + { key: 'ref', label: 'Reference' }, + ], +}; + +export const ghostSiteOutputSchema: OutputSchema = { + fields: [ + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'version', label: 'Ghost Version' }, + { key: 'locale', label: 'Locale' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'accent_color', label: 'Accent Color' }, + { key: 'logo', label: 'Logo', format: 'image' }, + { key: 'icon', label: 'Icon', format: 'image' }, + { key: 'cover_image', label: 'Cover Image', format: 'image' }, + { key: 'allow_external_signup', label: 'Allows External Signup', format: 'boolean' }, + { key: 'site_uuid', label: 'Site UUID' }, + ], +}; + +const navigationFields: Fields = [ + { key: 'label', label: 'Label' }, + { key: 'url', label: 'URL', format: 'url' }, +]; + +export const ghostSettingsOutputSchema: OutputSchema = { + fields: [ + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'timezone', label: 'Timezone' }, + { key: 'locale', label: 'Locale' }, + { key: 'accent_color', label: 'Accent Color' }, + { key: 'logo', label: 'Logo', format: 'image' }, + { key: 'icon', label: 'Icon', format: 'image' }, + { key: 'cover_image', label: 'Cover Image', format: 'image' }, + { key: 'meta_title', label: 'Meta Title' }, + { key: 'meta_description', label: 'Meta Description' }, + { key: 'og_image', label: 'Open Graph Image', format: 'image' }, + { key: 'og_title', label: 'Open Graph Title' }, + { key: 'og_description', label: 'Open Graph Description' }, + { key: 'twitter_image', label: 'X Card Image', format: 'image' }, + { key: 'twitter_title', label: 'X Card Title' }, + { key: 'twitter_description', label: 'X Card Description' }, + { key: 'facebook', label: 'Facebook' }, + { key: 'twitter', label: 'X (Twitter)' }, + { key: 'navigation', label: 'Navigation', labelKey: 'label', listItems: navigationFields }, + { + key: 'secondary_navigation', + label: 'Secondary Navigation', + labelKey: 'label', + listItems: navigationFields, + }, + { key: 'active_theme', label: 'Active Theme' }, + { key: 'is_private', label: 'Private Site', format: 'boolean' }, + { key: 'default_content_visibility', label: 'Default Content Visibility' }, + { key: 'members_enabled', label: 'Members Enabled', format: 'boolean' }, + { key: 'members_invite_only', label: 'Members Invite Only', format: 'boolean' }, + { key: 'members_signup_access', label: 'Member Signup Access' }, + { key: 'members_support_address', label: 'Members Support Address' }, + { key: 'paid_members_enabled', label: 'Paid Members Enabled', format: 'boolean' }, + { key: 'portal_name', label: 'Portal Asks for Name', format: 'boolean' }, + { key: 'portal_plans', label: 'Portal Plans' }, + { key: 'portal_default_plan', label: 'Portal Default Plan' }, + { key: 'comments_enabled', label: 'Comments Enabled' }, + { key: 'recommendations_enabled', label: 'Recommendations Enabled', format: 'boolean' }, + { key: 'email_track_opens', label: 'Track Email Opens', format: 'boolean' }, + { key: 'email_track_clicks', label: 'Track Email Clicks', format: 'boolean' }, + { key: 'donations_enabled', label: 'Donations Enabled', format: 'boolean' }, + { key: 'donations_currency', label: 'Donations Currency' }, + { key: 'donations_suggested_amount', label: 'Suggested Donation (cents)', format: 'number' }, + { key: 'editor_default_email_recipients', label: 'Default Email Recipients' }, + { key: 'default_email_address', label: 'Default Email Address', format: 'email' }, + { key: 'support_email_address', label: 'Support Email Address', format: 'email' }, + ], +}; + +export const ghostMemberStatsOutputSchema: OutputSchema = { + fields: [ + { key: 'total', label: 'Total Members', format: 'number' }, + { key: 'free', label: 'Free', format: 'number' }, + { key: 'paid', label: 'Paid', format: 'number' }, + { key: 'comped', label: 'Comped', format: 'number' }, + { key: 'gift', label: 'Gift', format: 'number' }, + { key: 'as_of', label: 'As Of', format: 'date' }, + { + key: 'history', + label: 'History', + labelKey: 'date', + listItems: [ + { key: 'date', label: 'Date', format: 'date' }, + { key: 'free', label: 'Free', format: 'number' }, + { key: 'paid', label: 'Paid', format: 'number' }, + { key: 'comped', label: 'Comped', format: 'number' }, + { key: 'gift', label: 'Gift', format: 'number' }, + ], + }, + ], +}; + +export const ghostListMemberActivityOutputSchema: OutputSchema = { + fields: [ + { + key: 'events', + label: 'Events', + labelKey: 'type', + listItems: [ + { key: 'type', label: 'Event Type' }, + { key: 'id', label: 'Event ID' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'member_id', label: 'Member ID' }, + { key: 'member_name', label: 'Member Name' }, + { key: 'member_email', label: 'Member Email', format: 'email' }, + { key: 'data', label: 'Event Data' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total', label: 'Total', format: 'number' }, + ], +}; + +export const ghostOriginalMembersOutputSchema: OutputSchema = { + fields: [ + { key: 'members', label: 'Members', labelKey: 'email', listItems: memberWithAttributionFields }, + ], +}; + +export const ghostOriginalFindMemberOutputSchema: OutputSchema = { + fields: [{ key: 'members', label: 'Members', labelKey: 'email', listItems: memberListFields }], +}; + +export const ghostOriginalFindUserOutputSchema: OutputSchema = { + fields: [{ key: 'users', label: 'Users', labelKey: 'name', listItems: userBaseFields }], +}; + +export const ghostOriginalCreatePostOutputSchema: OutputSchema = { + fields: [ + { + key: 'posts', + label: 'Posts', + labelKey: 'title', + listItems: postFields.filter((field) => field.key !== 'html'), + }, + ], +}; + +const triggerPostPreviousFields: Fields = [ + { key: 'status', label: 'Previous Status' }, + { key: 'published_at', label: 'Previous Published At', format: 'datetime' }, + { key: 'updated_at', label: 'Previous Updated At', format: 'datetime' }, +]; + +const triggerMemberFields: Fields = memberListFields; + +export const ghostMemberAddedTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Event' }, + { + key: 'member', + label: 'Member', + children: [{ key: 'current', label: 'Member', children: triggerMemberFields }], + }, + ], +}; + +export const ghostMemberEditedTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Event' }, + { + key: 'member', + label: 'Member', + children: [ + { key: 'current', label: 'Current Member', children: triggerMemberFields }, + { + key: 'previous', + label: 'Changed Fields (Previous Values)', + children: [ + { key: 'name', label: 'Previous Name' }, + { key: 'note', label: 'Previous Note' }, + { key: 'updated_at', label: 'Previous Updated At', format: 'datetime' }, + ], + }, + ], + }, + ], +}; + +export const ghostMemberDeletedTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Event' }, + { + key: 'member', + label: 'Member', + children: [{ key: 'previous', label: 'Deleted Member', children: memberCoreFields }], + }, + ], +}; + +export const ghostPostPublishedTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Event' }, + { + key: 'post', + label: 'Post', + children: [ + { key: 'current', label: 'Post', children: postFields }, + { key: 'previous', label: 'Previous Values', children: triggerPostPreviousFields }, + ], + }, + ], +}; + +export const ghostPagePublishedTriggerOutputSchema: OutputSchema = { + fields: [ + { key: 'event', label: 'Event' }, + { + key: 'page', + label: 'Page', + children: [{ key: 'current', label: 'Page', children: pageFields }], + }, + ], +}; diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/member-added.ts b/packages/pieces/community/ghostcms/src/lib/triggers/member-added.ts index 4afc85cec26c..6090ed006be5 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/member-added.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/member-added.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostMemberAddedTriggerOutputSchema } from '../output-schemas'; export const memberAdded = createTrigger({ auth: ghostAuth, name: 'member_added', + outputSchema: ghostMemberAddedTriggerOutputSchema, classification: 'READ', displayName: 'Member Added', description: 'Triggers when a new member is added', @@ -15,26 +17,24 @@ export const memberAdded = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'member.added', - context.webhookUrl - ); - - await context.store?.put('_member_added_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'member.added', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_member_added_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_member_added_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_member_added_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_member_added_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); return [context.payload.body]; }, diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/member-deleted.ts b/packages/pieces/community/ghostcms/src/lib/triggers/member-deleted.ts index 328586ec3259..ccacdccf6e53 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/member-deleted.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/member-deleted.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostMemberDeletedTriggerOutputSchema } from '../output-schemas'; export const memberDeleted = createTrigger({ auth: ghostAuth, name: 'member_deleted', + outputSchema: ghostMemberDeletedTriggerOutputSchema, classification: 'READ', displayName: 'Member Deleted', description: 'Triggers when a member is deleted', @@ -15,26 +17,24 @@ export const memberDeleted = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'member.deleted', - context.webhookUrl - ); - - await context.store?.put('_member_deleted_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'member.deleted', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_member_deleted_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_member_deleted_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_member_deleted_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_member_deleted_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); return [context.payload.body]; }, diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/member-edited.ts b/packages/pieces/community/ghostcms/src/lib/triggers/member-edited.ts index 067f5c42cda5..597160bdb0a7 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/member-edited.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/member-edited.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostMemberEditedTriggerOutputSchema } from '../output-schemas'; export const memberEdited = createTrigger({ auth: ghostAuth, name: 'member_edited', + outputSchema: ghostMemberEditedTriggerOutputSchema, classification: 'READ', displayName: 'Member Edited', description: 'Triggers when a member is edited', @@ -15,26 +17,24 @@ export const memberEdited = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'member.edited', - context.webhookUrl - ); - - await context.store?.put('_member_edited_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'member.edited', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_member_edited_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_member_edited_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_member_edited_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_member_edited_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); const payload = context.payload.body as any; if (payload.event != 'member.edited') { return []; diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/page-published.ts b/packages/pieces/community/ghostcms/src/lib/triggers/page-published.ts index e9ff0acb0dc5..13d079e3c66f 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/page-published.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/page-published.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostPagePublishedTriggerOutputSchema } from '../output-schemas'; export const pagePublished = createTrigger({ auth: ghostAuth, name: 'page_published', + outputSchema: ghostPagePublishedTriggerOutputSchema, classification: 'READ', displayName: 'Page Published', description: 'Triggers when a page is published', @@ -15,26 +17,24 @@ export const pagePublished = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'page.published', - context.webhookUrl - ); - - await context.store?.put('_page_published_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'page.published', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_page_published_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_page_published_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_page_published_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_page_published_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); return [context.payload.body]; }, diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/post-published.ts b/packages/pieces/community/ghostcms/src/lib/triggers/post-published.ts index 09cee27c41f3..b29ac4fadc72 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/post-published.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/post-published.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostPostPublishedTriggerOutputSchema } from '../output-schemas'; export const postPublished = createTrigger({ auth: ghostAuth, name: 'post_published', + outputSchema: ghostPostPublishedTriggerOutputSchema, classification: 'READ', displayName: 'Post Published', description: 'Triggers when a post is published', @@ -15,26 +17,24 @@ export const postPublished = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'post.published', - context.webhookUrl - ); - - await context.store?.put('_post_published_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'post.published', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_post_published_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_post_published_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_post_published_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_post_published_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); return [context.payload.body]; }, diff --git a/packages/pieces/community/ghostcms/src/lib/triggers/post-scheduled.ts b/packages/pieces/community/ghostcms/src/lib/triggers/post-scheduled.ts index dad3c1cee99d..d0518519b0a3 100644 --- a/packages/pieces/community/ghostcms/src/lib/triggers/post-scheduled.ts +++ b/packages/pieces/community/ghostcms/src/lib/triggers/post-scheduled.ts @@ -1,11 +1,13 @@ import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { ghostAuth } from '../..'; -import { common } from '../common'; +import { ghostAuth } from '../auth'; +import { ghostWebhook } from '../common/webhooks'; +import { ghostPostPublishedTriggerOutputSchema } from '../output-schemas'; export const postScheduled = createTrigger({ auth: ghostAuth, name: 'post_scheduled', + outputSchema: ghostPostPublishedTriggerOutputSchema, classification: 'READ', displayName: 'Post Scheduled', description: 'Triggers when a post is scheduled', @@ -15,26 +17,24 @@ export const postScheduled = createTrigger({ type: TriggerStrategy.WEBHOOK, props: {}, async onEnable(context) { - const webhookData: any = await common.subscribeWebhook( - context.auth, - 'post.scheduled', - context.webhookUrl - ); - - await context.store?.put('_post_scheduled_trigger', { - webhookId: webhookData.webhooks[0].id, + await ghostWebhook.enable({ + auth: context.auth, + event: 'post.scheduled', + webhookUrl: context.webhookUrl, + store: context.store, + storeKey: '_post_scheduled_trigger', }); }, async onDisable(context) { - const response: { - webhookId: string; - } | null = await context.store?.get('_post_scheduled_trigger'); - - if (response !== null && response !== undefined) { - await common.unsubscribeWebhook(context.auth, response.webhookId); - } + await ghostWebhook.disable({ auth: context.auth, store: context.store, storeKey: '_post_scheduled_trigger' }); }, async run(context) { + await ghostWebhook.assertSigned({ + store: context.store, + storeKey: '_post_scheduled_trigger', + rawBody: context.payload.rawBody, + headers: context.payload.headers, + }); return [context.payload.body]; }, diff --git a/packages/pieces/community/ghostcms/test/content.test.ts b/packages/pieces/community/ghostcms/test/content.test.ts new file mode 100644 index 000000000000..5cdec8cb546f --- /dev/null +++ b/packages/pieces/community/ghostcms/test/content.test.ts @@ -0,0 +1,114 @@ +/// + +import jwt from 'jsonwebtoken'; +import { ghostCreatePost } from '../src/lib/actions/ai/create-post'; +import { ghostListPosts } from '../src/lib/actions/ai/list-posts'; +import { ghostUpdatePage } from '../src/lib/actions/ai/update-page'; +import { ghostUpdatePost } from '../src/lib/actions/ai/update-post'; +import { ADMIN_URL, FIXTURE_KEY_ID, FIXTURE_KEY_SECRET, mockGhost, runAction } from './helpers'; + +const CURRENT = { id: 'p1', title: 'Old', updated_at: '2026-09-01T10:00:00.000Z' }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('requests', () => { + test('signs a short-lived admin JWT with the key id and sends it to the admin API of the connection', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [], meta: { pagination: { page: 1, pages: 1, total: 0 } } } }] }); + await runAction({ action: ghostListPosts, propsValue: {} }); + expect(requests[0].url).toBe(`${ADMIN_URL}/posts`); + const token = String(requests[0].headers?.['Authorization']).replace(/^Ghost /, ''); + const decoded = jwt.verify(token, Buffer.from(FIXTURE_KEY_SECRET, 'hex'), { audience: '/admin/', complete: true }); + expect(decoded.header.kid).toBe(FIXTURE_KEY_ID); + }); + + test('list posts sends paging defaults, includes tags and authors, and drops the body unless asked', async () => { + const requests = mockGhost({ + replies: [ + { + body: { + posts: [{ id: 'p1', title: 'T', html: '

x

', lexical: '{}' }], + meta: { pagination: { page: 1, limit: 15, pages: 3, total: 31, next: 2, prev: null } }, + }, + }, + ], + }); + const result = await runAction({ action: ghostListPosts, propsValue: {} }); + expect(requests[0].method).toBe('GET'); + expect(requests[0].queryParams).toEqual({ limit: '15', page: '1', include: 'tags,authors' }); + expect(result).toEqual({ posts: [{ id: 'p1', title: 'T' }], page: 1, limit: 15, total_pages: 3, total: 31, next: 2, prev: null }); + }); + + test('list posts asks for html only when Include Content is on', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [{ id: 'p1', html: '

x

' }], meta: {} } }] }); + const result = await runAction({ action: ghostListPosts, propsValue: { include_content: true, limit: 500 } }); + expect(requests[0].queryParams).toEqual({ limit: '100', page: '1', include: 'tags,authors', formats: 'html' }); + expect(result).toMatchObject({ posts: [{ id: 'p1', html: '

x

' }] }); + }); + + test('create post wraps the body as a draft, marks the html source and turns tag names into objects', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [{ id: 'p1' }] } }] }); + await runAction({ action: ghostCreatePost, propsValue: { title: ' Hello ', html: '

x

', tags: ['news', ' '] } }); + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe(`${ADMIN_URL}/posts`); + expect(requests[0].queryParams).toEqual({ formats: 'html', include: 'tags,authors', source: 'html' }); + expect(requests[0].body).toEqual({ posts: [{ title: 'Hello', html: '

x

', tags: [{ name: 'news' }], status: 'draft' }] }); + }); +}); + +describe('updates have three states', () => { + test('omitted fields are not sent and updated_at comes from a fresh read', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [CURRENT] } }, { body: { posts: [{ id: 'p1' }] } }] }); + await runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1', custom_excerpt: 'New excerpt' } }); + expect(requests.map((request) => request.method)).toEqual(['GET', 'PUT']); + expect(requests[1].url).toBe(`${ADMIN_URL}/posts/p1`); + expect(requests[1].body).toEqual({ posts: [{ custom_excerpt: 'New excerpt', updated_at: CURRENT.updated_at }] }); + }); + + test('an explicit empty Tags list clears the tags', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [CURRENT] } }, { body: { posts: [{ id: 'p1' }] } }] }); + await runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1', tags: [] } }); + expect(requests[1].body).toEqual({ posts: [{ tags: [], updated_at: CURRENT.updated_at }] }); + }); + + test('an empty Authors list is refused before any request, because Ghost needs one author', async () => { + const requests = mockGhost({ replies: [] }); + await expect(runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1', authors: [] } })).rejects.toThrow( + 'Ghost requires at least one author' + ); + expect(requests).toHaveLength(0); + }); + + test('Clear Fields sends null for the listed fields', async () => { + const requests = mockGhost({ replies: [{ body: { posts: [CURRENT] } }, { body: { posts: [{ id: 'p1' }] } }] }); + await runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1', clear_fields: ['custom_excerpt', 'meta_title'] } }); + expect(requests[1].body).toEqual({ + posts: [{ custom_excerpt: null, meta_title: null, updated_at: CURRENT.updated_at }], + }); + }); + + test('a field that is both set and cleared is refused before any request', async () => { + const requests = mockGhost({ replies: [] }); + await expect( + runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1', meta_title: 'SEO', clear_fields: ['meta_title'] } }) + ).rejects.toThrow('both set to a new value and listed in Clear Fields'); + expect(requests).toHaveLength(0); + }); + + test('an update with nothing to change is refused before any request', async () => { + const requests = mockGhost({ replies: [] }); + await expect(runAction({ action: ghostUpdatePost, propsValue: { post_id: 'p1' } })).rejects.toThrow( + 'Provide at least one field to change.' + ); + expect(requests).toHaveLength(0); + }); + + test('scheduling a page requires a future publish time', async () => { + const requests = mockGhost({ replies: [] }); + await expect( + runAction({ action: ghostUpdatePage, propsValue: { page_id: 'g1', status: 'scheduled', published_at: '2020-01-01T00:00:00Z' } }) + ).rejects.toThrow('Published At must be in the future.'); + expect(requests).toHaveLength(0); + }); +}); diff --git a/packages/pieces/community/ghostcms/test/helpers.ts b/packages/pieces/community/ghostcms/test/helpers.ts new file mode 100644 index 000000000000..db11eccc16e8 --- /dev/null +++ b/packages/pieces/community/ghostcms/test/helpers.ts @@ -0,0 +1,72 @@ +/// + +import { HttpRequest, HttpResponse, httpClient } from '@activepieces/pieces-common'; +import { + ActionContext, + AppConnectionType, + InputPropertyMap, + StaticPropsValue, +} from '@activepieces/pieces-framework'; +import { createMockActionContext } from '../../../framework/src/lib/test'; +import { ghostAuth } from '../src/lib/auth'; + +export function mockGhost({ replies }: { replies: Reply[] }): HttpRequest[] { + const requests: HttpRequest[] = []; + const queue = [...replies]; + vi.spyOn(httpClient, 'sendRequest').mockImplementation(async (request: HttpRequest): Promise => { + requests.push(request); + const reply = queue.shift(); + if (!reply) { + throw new Error(`unexpected request ${request.method} ${request.url}`); + } + if ('error' in reply) { + throw Object.assign(new Error(`Request failed with status ${reply.error.status}`), { + response: { status: reply.error.status, body: reply.error.body }, + }); + } + return { status: reply.status ?? 200, headers: {}, body: reply.body }; + }); + return requests; +} + +export function memoryStore({ initial }: { initial?: Record } = {}) { + const data = new Map(Object.entries(initial ?? {})); + return { + data, + put: async (key: string, value: unknown): Promise => { + data.set(key, value); + return value; + }, + get: async (key: string): Promise => data.get(key) ?? null, + delete: async (key: string): Promise => { + data.delete(key); + }, + }; +} + +export function runAction({ action, propsValue }: RunActionParams) { + const context: ActionContext = { + ...createMockActionContext({ propsValue }), + auth: { type: AppConnectionType.CUSTOM_AUTH, props: FIXTURE_AUTH.props }, + }; + return action.run(context); +} + +export const FIXTURE_KEY_ID = '6aba8a1beab048bef80c3b7e'; +export const FIXTURE_KEY_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; +export const FIXTURE_AUTH = { + props: { baseUrl: 'https://blog.example.com/', apiKey: `${FIXTURE_KEY_ID}:${FIXTURE_KEY_SECRET}` }, +}; +export const ADMIN_URL = 'https://blog.example.com/ghost/api/admin'; + +type GhostAuth = typeof ghostAuth; + +type Reply = { status?: number; body: unknown } | { error: { status: number; body: unknown } }; + +type RunActionParams = { + action: { + props: Props; + run: (context: ActionContext) => Promise; + }; + propsValue: StaticPropsValue; +}; diff --git a/packages/pieces/community/ghostcms/test/members.test.ts b/packages/pieces/community/ghostcms/test/members.test.ts new file mode 100644 index 000000000000..0adf1a43f229 --- /dev/null +++ b/packages/pieces/community/ghostcms/test/members.test.ts @@ -0,0 +1,175 @@ +/// + +import { ghostAddMemberLabel } from '../src/lib/actions/ai/add-member-label'; +import { ghostDeleteMember } from '../src/lib/actions/ai/delete-member'; +import { ghostGetMemberByEmail } from '../src/lib/actions/ai/get-member-by-email'; +import { ghostGetMemberStats } from '../src/lib/actions/ai/get-member-stats'; +import { ghostGetSettings } from '../src/lib/actions/ai/get-settings'; +import { ghostSubscribeMemberToNewsletters } from '../src/lib/actions/ai/subscribe-member-to-newsletters'; +import { ghostUnsubscribeMemberFromNewsletters } from '../src/lib/actions/ai/unsubscribe-member-from-newsletters'; +import { ghostUpdateMember } from '../src/lib/actions/ai/update-member'; +import { ghostUpdateTier } from '../src/lib/actions/ai/update-tier'; +import { ADMIN_URL, FIXTURE_KEY_SECRET, mockGhost, runAction } from './helpers'; + +const MEMBER = { id: 'm1', email: 'a@example.com', newsletters: [{ id: 'n1' }], labels: [] }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('member lookups', () => { + test('get member by email quotes the email so a plus sign stays literal', async () => { + const requests = mockGhost({ replies: [{ body: { members: [MEMBER] } }] }); + const result = await runAction({ action: ghostGetMemberByEmail, propsValue: { email: ' jane+news@example.com ' } }); + expect(requests[0].queryParams).toEqual({ filter: "email:'jane+news@example.com'", limit: '1' }); + expect(result).toEqual({ found: true, member: MEMBER }); + }); + + test('get member by email escapes a quote inside the email', async () => { + const requests = mockGhost({ replies: [{ body: { members: [] } }] }); + const result = await runAction({ action: ghostGetMemberByEmail, propsValue: { email: "o'neil@example.com" } }); + expect(requests[0].queryParams?.['filter']).toBe("email:'o\\'neil@example.com'"); + expect(result).toEqual({ found: false, member: null }); + }); + + test('member stats reads the top-level total and the latest daily row', async () => { + mockGhost({ + replies: [ + { + body: { + total: 12, + data: [ + { date: '2026-09-27', free: 9, paid: 1, comped: 0 }, + { date: '2026-09-28', free: 10, paid: 2, comped: 0 }, + ], + }, + }, + ], + }); + const result = await runAction({ action: ghostGetMemberStats, propsValue: {} }); + expect(result).toMatchObject({ total: 12, free: 10, paid: 2, as_of: '2026-09-28' }); + }); + + test('get settings never returns secret settings', async () => { + mockGhost({ + replies: [ + { + body: { + settings: [ + { key: 'title', value: 'Blog' }, + { key: 'stripe_secret_key', value: 'sk_live_x' }, + { key: 'mailgun_api_key', value: 'key-x' }, + { key: 'members_private_key', value: 'pk' }, + ], + }, + }, + ], + }); + const result = JSON.stringify(await runAction({ action: ghostGetSettings, propsValue: {} })); + expect(result).toContain('Blog'); + expect(result).not.toMatch(/sk_live_x|key-x|"pk"/); + }); +}); + +describe('member writes', () => { + test('add label uses the bulk endpoint filtered to the one member', async () => { + const requests = mockGhost({ + replies: [ + { body: { members: [MEMBER] } }, + { body: { bulk: { meta: { stats: { successful: 1, unsuccessful: 0 } } } } }, + { body: { members: [{ ...MEMBER, labels: [{ id: 'l1' }] }] } }, + ], + }); + await runAction({ action: ghostAddMemberLabel, propsValue: { member_id: 'm1', label_id: 'l1' } }); + expect(requests[1].method).toBe('PUT'); + expect(requests[1].url).toBe(`${ADMIN_URL}/members/bulk`); + expect(requests[1].queryParams).toEqual({ filter: "id:'m1'" }); + expect(requests[1].body).toEqual({ bulk: { action: 'addLabel', meta: { label: { id: 'l1' } } } }); + }); + + test('a label change Ghost reports as unsuccessful fails the step', async () => { + const requests = mockGhost({ + replies: [ + { body: { members: [MEMBER] } }, + { body: { bulk: { meta: { stats: { successful: 0, unsuccessful: 1 }, errors: [{ message: 'Label not found' }] } } } }, + ], + }); + await expect(runAction({ action: ghostAddMemberLabel, propsValue: { member_id: 'm1', label_id: 'nope' } })).rejects.toThrow( + 'Ghost could not update the member label' + ); + expect(requests).toHaveLength(2); + }); + + test('unsubscribe removes each newsletter with its own bulk call and never rewrites the list', async () => { + const requests = mockGhost({ + replies: [ + { body: { members: [MEMBER] } }, + { body: { bulk: { meta: { stats: { successful: 1, unsuccessful: 0 } } } } }, + { body: { bulk: { meta: { stats: { successful: 1, unsuccessful: 0 } } } } }, + { body: { members: [{ ...MEMBER, newsletters: [] }] } }, + ], + }); + await runAction({ + action: ghostUnsubscribeMemberFromNewsletters, + propsValue: { member_id: 'm1', newsletter_ids: ['n1', 'n2', 'n1'] }, + }); + const bulk = requests.filter((request) => request.url.endsWith('/members/bulk')); + expect(bulk.map((request) => request.body)).toEqual([ + { bulk: { action: 'unsubscribe', newsletter: 'n1' } }, + { bulk: { action: 'unsubscribe', newsletter: 'n2' } }, + ]); + expect(requests.some((request) => request.method === 'PUT' && request.url.endsWith('/members/m1'))).toBe(false); + }); + + test('an unsubscribe Ghost reports as unsuccessful fails the step', async () => { + const requests = mockGhost({ + replies: [ + { body: { members: [MEMBER] } }, + { body: { bulk: { action: 'unsubscribe', meta: { stats: { successful: 0, unsuccessful: 1 }, errors: [{ message: 'Newsletter not found' }] } } } }, + ], + }); + await expect( + runAction({ action: ghostUnsubscribeMemberFromNewsletters, propsValue: { member_id: 'm1', newsletter_ids: ['nope'] } }) + ).rejects.toThrow('Ghost could not remove the newsletter subscription'); + expect(requests).toHaveLength(2); + }); + + test('subscribe keeps the existing newsletters and adds the new ones', async () => { + const requests = mockGhost({ replies: [{ body: { members: [MEMBER] } }, { body: { members: [MEMBER] } }] }); + await runAction({ action: ghostSubscribeMemberToNewsletters, propsValue: { member_id: 'm1', newsletter_ids: ['n2', 'n1'] } }); + expect(requests[1].body).toEqual({ members: [{ newsletters: [{ id: 'n1' }, { id: 'n2' }] }] }); + }); + + test('update member leaves omitted lists alone, clears an empty list and blanks cleared text', async () => { + const requests = mockGhost({ replies: [{ body: { members: [MEMBER] } }] }); + await runAction({ action: ghostUpdateMember, propsValue: { member_id: 'm1', labels: [], clear_fields: ['note'] } }); + expect(requests[0].body).toEqual({ members: [{ note: '', labels: [] }] }); + }); + + test('delete member only cancels Stripe subscriptions when asked', async () => { + const requests = mockGhost({ replies: [{ status: 204, body: '' }, { status: 204, body: '' }] }); + await runAction({ action: ghostDeleteMember, propsValue: { member_id: 'm1' } }); + await runAction({ action: ghostDeleteMember, propsValue: { member_id: 'm1', cancel_stripe_subscriptions: true } }); + expect(requests.map((request) => request.queryParams)).toEqual([{}, { cancel: 'true' }]); + }); + + test('update tier sends an empty Benefits list to clear benefits and null for a cleared description', async () => { + const requests = mockGhost({ replies: [{ body: { tiers: [{ id: 't1' }] } }] }); + await runAction({ action: ghostUpdateTier, propsValue: { tier_id: 't1', benefits: [], clear_fields: ['description'] } }); + expect(requests[0].body).toEqual({ tiers: [{ benefits: [], description: null }] }); + }); +}); + +describe('errors', () => { + test('an API error names the status and never carries the key or token', async () => { + mockGhost({ replies: [{ error: { status: 404, body: { errors: [{ message: 'Member not found.' }] } } }] }); + const error = await runAction({ action: ghostUpdateMember, propsValue: { member_id: 'gone', name: 'X' } }).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + const text = error instanceof Error ? error.message : ''; + expect(text).toContain('Ghost could not find the requested resource (404)'); + expect(text).not.toContain(FIXTURE_KEY_SECRET); + expect(text).not.toMatch(/Ghost eyJ/); + }); +}); diff --git a/packages/pieces/community/ghostcms/test/security.test.ts b/packages/pieces/community/ghostcms/test/security.test.ts new file mode 100644 index 000000000000..f830463cd013 --- /dev/null +++ b/packages/pieces/community/ghostcms/test/security.test.ts @@ -0,0 +1,122 @@ +/// + +import crypto from 'crypto'; +import { ghostAuth } from '../src/lib/auth'; +import { assertGhostUrl } from '../src/lib/common/custom-api-guard'; +import { ghostWebhook } from '../src/lib/common/webhooks'; +import { ADMIN_URL, FIXTURE_AUTH, memoryStore, mockGhost } from './helpers'; + +const STORE_KEY = '_member_added_trigger'; +const SERVER = { apiUrl: 'http://localhost:3000', publicUrl: 'http://localhost:4200', mintOidcToken: async () => '' }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('Custom API Call only sends the key to the connection admin API', () => { + test.each(['/posts/', 'posts/?limit=1', `${ADMIN_URL}/site/`, `${ADMIN_URL}?x=1`])('allows %s', (url) => { + expect(() => assertGhostUrl({ auth: FIXTURE_AUTH, propsValue: { url: { url } } })).not.toThrow(); + }); + + test.each([ + 'https://blog.example.com.evil.io/ghost/api/admin/site/', + 'https://evil.io/ghost/api/admin/site/', + 'http://blog.example.com/ghost/api/admin/site/', + 'https://blog.example.com/ghost/api/content/posts/', + '//evil.io/x', + 'HTTPS://evil.io/', + '/../content/', + '/%2e%2e/content/', + ])('refuses %s', (url) => { + expect(() => assertGhostUrl({ auth: FIXTURE_AUTH, propsValue: { url: { url } } })).toThrow( + 'Custom API Call only sends your Ghost Admin API key' + ); + }); +}); + +describe('webhook triggers', () => { + test('enable registers the webhook with a signing secret and stores both', async () => { + const requests = mockGhost({ replies: [{ status: 201, body: { webhooks: [{ id: 'w1' }] } }] }); + const store = memoryStore(); + await ghostWebhook.enable({ auth: FIXTURE_AUTH, event: 'member.added', webhookUrl: 'https://ap.test/hook', store, storeKey: STORE_KEY }); + const sent = JSON.stringify(requests[0].body); + const stored = store.data.get(STORE_KEY); + expect(requests[0].url).toBe(`${ADMIN_URL}/webhooks`); + expect(sent).toContain('"event":"member.added"'); + expect(stored).toMatchObject({ webhookId: 'w1' }); + expect(JSON.stringify(stored)).toMatch(/"secret":"[0-9a-f]{64}"/); + }); + + test('enable deletes the webhook it created when the store write fails', async () => { + const requests = mockGhost({ replies: [{ status: 201, body: { webhooks: [{ id: 'w1' }] } }, { status: 204, body: '' }] }); + const store = { ...memoryStore(), put: async () => Promise.reject(new Error('store down')) }; + await expect( + ghostWebhook.enable({ auth: FIXTURE_AUTH, event: 'member.added', webhookUrl: 'https://ap.test/hook', store, storeKey: STORE_KEY }) + ).rejects.toThrow('store down'); + expect(requests[1].method).toBe('DELETE'); + expect(requests[1].url).toBe(`${ADMIN_URL}/webhooks/w1`); + }); + + test('disable forgets the webhook after a confirmed delete, and a 404 counts as deleted', async () => { + mockGhost({ replies: [{ error: { status: 404, body: { errors: [{ message: 'Webhook not found.' }] } } }] }); + const store = memoryStore({ initial: { [STORE_KEY]: { webhookId: 'w1', secret: 's' } } }); + await ghostWebhook.disable({ auth: FIXTURE_AUTH, store, storeKey: STORE_KEY }); + expect(store.data.has(STORE_KEY)).toBe(false); + }); + + test('disable keeps the webhook ID when Ghost fails to delete it', async () => { + mockGhost({ replies: [{ error: { status: 500, body: { errors: [{ message: 'boom' }] } } }] }); + const store = memoryStore({ initial: { [STORE_KEY]: { webhookId: 'w1', secret: 's' } } }); + await expect(ghostWebhook.disable({ auth: FIXTURE_AUTH, store, storeKey: STORE_KEY })).rejects.toThrow('(500)'); + expect(store.data.has(STORE_KEY)).toBe(true); + }); + + test('a delivery signed with the stored secret is accepted, and a tampered or unsigned one is refused', async () => { + const secret = 'a'.repeat(64); + const rawBody = '{"member":{"current":{"id":"m1"}}}'; + const timestamp = '1790623913000'; + const signature = crypto.createHmac('sha256', secret).update(`${rawBody}${timestamp}`).digest('hex'); + const store = memoryStore({ initial: { [STORE_KEY]: { webhookId: 'w1', secret } } }); + const headers = { 'X-Ghost-Signature': `sha256=${signature}, t=${timestamp}` }; + await expect(ghostWebhook.assertSigned({ store, storeKey: STORE_KEY, rawBody, headers })).resolves.toBeUndefined(); + await expect( + ghostWebhook.assertSigned({ store, storeKey: STORE_KEY, rawBody: rawBody.replace('m1', 'm2'), headers }) + ).rejects.toThrow('did not match'); + await expect(ghostWebhook.assertSigned({ store, storeKey: STORE_KEY, rawBody, headers: {} })).rejects.toThrow('did not match'); + }); + + test('a trigger enabled before signing existed keeps accepting deliveries', async () => { + const store = memoryStore({ initial: { [STORE_KEY]: { webhookId: 'w1' } } }); + await expect(ghostWebhook.assertSigned({ store, storeKey: STORE_KEY, rawBody: '{}', headers: {} })).resolves.toBeUndefined(); + }); +}); + +describe('connection check', () => { + const validate = ghostAuth.validate; + + test('a working key is valid', async () => { + const requests = mockGhost({ replies: [{ body: { users: [] } }] }); + await expect(validate?.({ auth: { baseUrl: 'https://blog.example.com', apiKey: FIXTURE_AUTH.props.apiKey }, server: SERVER })).resolves.toEqual({ valid: true }); + expect(requests[0].url).toBe(`${ADMIN_URL}/users/`); + }); + + test('only a 401 or 403 is reported as an invalid key', async () => { + mockGhost({ replies: [{ error: { status: 401, body: {} } }, { error: { status: 500, body: {} } }] }); + const auth = { baseUrl: 'https://blog.example.com', apiKey: FIXTURE_AUTH.props.apiKey }; + const rejected = await validate?.({ auth, server: SERVER }); + const unreachable = await validate?.({ auth, server: SERVER }); + expect(rejected).toMatchObject({ valid: false, error: expect.stringContaining('Invalid Admin API Key') }); + expect(unreachable).toMatchObject({ valid: false, error: expect.stringContaining('Could not verify the connection') }); + expect(JSON.stringify(unreachable)).not.toContain('Invalid Admin API Key'); + }); + + test.each(['ftp://blog.example.com', 'blog.example.com', 'https://user:pass@blog.example.com'])( + 'refuses the API URL %s without calling Ghost', + async (baseUrl) => { + const requests = mockGhost({ replies: [] }); + const result = await validate?.({ auth: { baseUrl, apiKey: FIXTURE_AUTH.props.apiKey }, server: SERVER }); + expect(result).toMatchObject({ valid: false }); + expect(requests).toHaveLength(0); + } + ); +}); diff --git a/packages/pieces/community/ghostcms/vitest.config.ts b/packages/pieces/community/ghostcms/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/ghostcms/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + 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'), + }, + }, +}) diff --git a/packages/pieces/community/jsm-operations/.eslintrc.json b/packages/pieces/community/jsm-operations/.eslintrc.json new file mode 100644 index 000000000000..9ee1a0770e89 --- /dev/null +++ b/packages/pieces/community/jsm-operations/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "extends": ["../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": {} }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "lodash", + "lodash/*", + "@activepieces/core-*", + "@activepieces/server*", + "@activepieces/engine", + "@activepieces/shared" + ] + } + ] + } + }, + { "files": ["*.js", "*.jsx"], "rules": {} } + ] +} diff --git a/packages/pieces/community/jsm-operations/package.json b/packages/pieces/community/jsm-operations/package.json new file mode 100644 index 000000000000..96659d18f567 --- /dev/null +++ b/packages/pieces/community/jsm-operations/package.json @@ -0,0 +1,20 @@ +{ + "name": "@activepieces/piece-jsm-operations", + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "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'", + "test": "vitest run" + }, + "dependencies": { + "@activepieces/pieces-common": "workspace:*", + "@activepieces/pieces-framework": "workspace:*" + }, + "devDependencies": { + "tslib": "2.6.2", + "vitest": "3.2.6" + } +} diff --git a/packages/pieces/community/jsm-operations/src/i18n/translation.json b/packages/pieces/community/jsm-operations/src/i18n/translation.json new file mode 100644 index 000000000000..385f73648b29 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/i18n/translation.json @@ -0,0 +1,145 @@ +{ + "Opsgenie alerts in Jira Service Management: create, acknowledge, close and route on-call alerts, and see who is on call.": "Opsgenie alerts in Jira Service Management: create, acknowledge, close and route on-call alerts, and see who is on call.", + "Create Alert": "Create Alert", + "Get Alert": "Get Alert", + "Find Alerts": "Find Alerts", + "Acknowledge Alert": "Acknowledge Alert", + "Unacknowledge Alert": "Unacknowledge Alert", + "Close Alert": "Close Alert", + "Add Note to Alert": "Add Note to Alert", + "Add Tags to Alert": "Add Tags to Alert", + "Remove Tags from Alert": "Remove Tags from Alert", + "Assign Alert": "Assign Alert", + "Add Responder to Alert": "Add Responder to Alert", + "Escalate Alert": "Escalate Alert", + "Snooze Alert": "Snooze Alert", + "Update Alert": "Update Alert", + "Get Who Is On Call": "Get Who Is On Call", + "Custom API Call": "Custom API Call", + "Create a new alert and route it to teams, users or schedules.": "Create a new alert and route it to teams, users or schedules.", + "Get one alert by its ID or alias.": "Get one alert by its ID or alias.", + "Search alerts with a JSM Operations search query.": "Search alerts with a JSM Operations search query.", + "Acknowledge an alert so responders know someone is on it.": "Acknowledge an alert so responders know someone is on it.", + "Put an acknowledged alert back to open.": "Put an acknowledged alert back to open.", + "Close an alert once the problem is resolved.": "Close an alert once the problem is resolved.", + "Add a note to an alert.": "Add a note to an alert.", + "Add one or more tags to an alert.": "Add one or more tags to an alert.", + "Remove one or more tags from an alert.": "Remove one or more tags from an alert.", + "Make a user the owner of an alert. Not available on the JSM Free plan.": "Make a user the owner of an alert. Not available on the JSM Free plan.", + "Add a team, user, schedule or escalation as a responder.": "Add a team, user, schedule or escalation as a responder.", + "Escalate an alert to the next level of an escalation policy.": "Escalate an alert to the next level of an escalation policy.", + "Pause notifications for an alert until a set time. Not on JSM Free.": "Pause notifications for an alert until a set time. Not on JSM Free.", + "Change the message, description or priority of an alert.": "Change the message, description or priority of an alert.", + "Get who is on call for a schedule, now or at a given time.": "Get who is on call for a schedule, now or at a given time.", + "Call any JSM Operations endpoint with this connection.": "Call any JSM Operations endpoint with this connection.", + "Message": "Message", + "Description": "Description", + "Priority": "Priority", + "Alias": "Alias", + "Responder Teams": "Responder Teams", + "Responder Users": "Responder Users", + "Responder Schedules": "Responder Schedules", + "Responder Escalations": "Responder Escalations", + "Responders by Name (API Key Connections)": "Responders by Name (API Key Connections)", + "Tags": "Tags", + "Entity": "Entity", + "Source": "Source", + "Note": "Note", + "Extra Properties": "Extra Properties", + "Custom Actions": "Custom Actions", + "Created By": "Created By", + "Alert ID or Alias": "Alert ID or Alias", + "Identify Alert By": "Identify Alert By", + "Search Query": "Search Query", + "Sort By": "Sort By", + "Order": "Order", + "Max Results": "Max Results", + "Skip": "Skip", + "Done By": "Done By", + "Added By": "Added By", + "Assignee": "Assignee", + "Responder Type": "Responder Type", + "Responder": "Responder", + "Escalation": "Escalation", + "Snooze Until": "Snooze Until", + "New Message": "New Message", + "New Description": "New Description", + "Schedule": "Schedule", + "At Time": "At Time", + "Method": "Method", + "Headers": "Headers", + "Query Parameters": "Query Parameters", + "Body Type": "Body Type", + "Body": "Body", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", + "Follow redirects": "Follow redirects", + "Short summary of the problem. Up to 130 characters.": "Short summary of the problem. Up to 130 characters.", + "Full details of the alert. Up to 15,000 characters.": "Full details of the alert. Up to 15,000 characters.", + "P1 is the most urgent, P5 the least.": "P1 is the most urgent, P5 the least.", + "Your own unique key for the alert. Open duplicates are merged.": "Your own unique key for the alert. Open duplicates are merged.", + "Teams to notify. Needs an Atlassian Account connection.": "Teams to notify. Needs an Atlassian Account connection.", + "Users to notify. Needs an Atlassian Account connection.": "Users to notify. Needs an Atlassian Account connection.", + "Schedules whose on-call users are notified.": "Schedules whose on-call users are notified.", + "Escalation policies to start for this alert.": "Escalation policies to start for this alert.", + "For API key connections: responders by team name or user email.": "For API key connections: responders by team name or user email.", + "Labels for searching and grouping alerts.": "Labels for searching and grouping alerts.", + "The server, service or app the alert is about.": "The server, service or app the alert is about.", + "The tool that raised the alert.": "The tool that raised the alert.", + "A first note to attach to the alert.": "A first note to attach to the alert.", + "Custom key-value details shown on the alert.": "Custom key-value details shown on the alert.", + "Names of custom alert actions to show on the alert.": "Names of custom alert actions to show on the alert.", + "API key connections only: who is shown as the creator.": "API key connections only: who is shown as the creator.", + "The alert ID, or its alias if you pick Alias below.": "The alert ID, or its alias if you pick Alias below.", + "Whether the value above is the alert ID or its alias.": "Whether the value above is the alert ID or its alias.", + "Alert search syntax. Leave empty to list all alerts.": "Alert search syntax. Leave empty to list all alerts.", + "How many alerts to return, from 1 to 100.": "How many alerts to return, from 1 to 100.", + "How many matching alerts to skip, for paging.": "How many matching alerts to skip, for paging.", + "Optional note to add to the alert at the same time.": "Optional note to add to the alert at the same time.", + "API key connections only: the name shown for this change.": "API key connections only: the name shown for this change.", + "API key connections only: where the change came from.": "API key connections only: where the change came from.", + "The note text. Up to 25,000 characters.": "The note text. Up to 25,000 characters.", + "API key connections only: the name shown on the note.": "API key connections only: the name shown on the note.", + "API key connections only: where the note came from.": "API key connections only: where the note came from.", + "Tags to add to the alert.": "Tags to add to the alert.", + "Tags to remove from the alert.": "Tags to remove from the alert.", + "The user who will own the alert. Type to search.": "The user who will own the alert. Type to search.", + "What kind of responder to add.": "What kind of responder to add.", + "The team, user, schedule or escalation to add.": "The team, user, schedule or escalation to add.", + "The escalation policy to use, listed with its team.": "The escalation policy to use, listed with its team.", + "When notifications should start again. Must be in the future.": "When notifications should start again. Must be in the future.", + "Leave empty to keep the current message. Up to 130 characters.": "Leave empty to keep the current message. Up to 130 characters.", + "Leave empty to keep the current description.": "Leave empty to keep the current description.", + "The on-call schedule to check.": "The on-call schedule to check.", + "Leave empty to check who is on call right now.": "Leave empty to check who is on call right now.", + "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.", + "P1 - Critical": "P1 - Critical", + "P2 - High": "P2 - High", + "P3 - Moderate": "P3 - Moderate", + "P4 - Low": "P4 - Low", + "P5 - Informational": "P5 - Informational", + "Alert ID": "Alert ID", + "Created at": "Created at", + "Last occurred at": "Last occurred at", + "Inserted at": "Inserted at", + "Newest / highest first": "Newest / highest first", + "Oldest / lowest first": "Oldest / lowest first", + "Team": "Team", + "User": "User", + "GET": "GET", + "POST": "POST", + "PATCH": "PATCH", + "PUT": "PUT", + "DELETE": "DELETE", + "HEAD": "HEAD", + "New Alert": "New Alert", + "Triggers when a new alert is created.": "Triggers when a new alert is created.", + "Only Alerts Matching": "Only Alerts Matching", + "Optional alert search query to filter new alerts.": "Optional alert search query to filter new alerts." +} \ No newline at end of file diff --git a/packages/pieces/community/jsm-operations/src/index.ts b/packages/pieces/community/jsm-operations/src/index.ts new file mode 100644 index 000000000000..9e077ba54e89 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/index.ts @@ -0,0 +1,56 @@ +import { createCustomApiCallAction } from '@activepieces/pieces-common'; +import { createPiece, PieceCategory } from '@activepieces/pieces-framework'; +import { acknowledgeAlertAction } from './lib/actions/acknowledge-alert'; +import { addNoteAction } from './lib/actions/add-note'; +import { addResponderAction } from './lib/actions/add-responder'; +import { addTagsAction } from './lib/actions/add-tags'; +import { assignAlertAction } from './lib/actions/assign-alert'; +import { closeAlertAction } from './lib/actions/close-alert'; +import { createAlertAction } from './lib/actions/create-alert'; +import { jsmCustomApiCall } from './lib/actions/custom-api-call'; +import { escalateAlertAction } from './lib/actions/escalate-alert'; +import { findAlertsAction } from './lib/actions/find-alerts'; +import { getAlertAction } from './lib/actions/get-alert'; +import { getOnCallAction } from './lib/actions/get-on-call'; +import { removeTagsAction } from './lib/actions/remove-tags'; +import { snoozeAlertAction } from './lib/actions/snooze-alert'; +import { unacknowledgeAlertAction } from './lib/actions/unacknowledge-alert'; +import { updateAlertAction } from './lib/actions/update-alert'; +import { jsmOpsAuth } from './lib/auth'; +import { newAlertTrigger } from './lib/triggers/new-alert'; + +export const jsmOperations = createPiece({ + displayName: 'Jira Service Management Operations', + description: + 'Opsgenie alerts in Jira Service Management: create, acknowledge, close and route on-call alerts, and see who is on call.', + minimumSupportedRelease: '0.88.2', + logoUrl: 'https://cdn.activepieces.com/pieces/jira.png', + categories: [PieceCategory.DEVELOPER_TOOLS], + auth: jsmOpsAuth, + authors: ['OdaiAhmed99'], + actions: [ + createAlertAction, + getAlertAction, + findAlertsAction, + acknowledgeAlertAction, + unacknowledgeAlertAction, + closeAlertAction, + addNoteAction, + addTagsAction, + removeTagsAction, + assignAlertAction, + addResponderAction, + escalateAlertAction, + snoozeAlertAction, + updateAlertAction, + getOnCallAction, + jsmCustomApiCall.withCloudId( + createCustomApiCallAction({ + auth: jsmOpsAuth, + baseUrl: jsmCustomApiCall.baseUrl, + authMapping: jsmCustomApiCall.authMapping, + }), + ), + ], + triggers: [newAlertTrigger], +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/acknowledge-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/acknowledge-alert.ts new file mode 100644 index 000000000000..acf8a4e9efc2 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/acknowledge-alert.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { alertActionOutputSchema } from '../output-schemas'; + +export const acknowledgeAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'acknowledge_alert', + classification: 'WRITE', + displayName: 'Acknowledge Alert', + description: 'Acknowledge an alert so responders know someone is on it.', + audience: 'both', + aiMetadata: { + description: + 'Acknowledges an open JSM Operations or Opsgenie alert by ID or alias, optionally adding a note, and waits briefly for the result. Works with both connection types. Acknowledging an already acknowledged alert has no further effect, so it is safe to retry. If the optional note cannot be added, the alert is still acknowledged and note_added is false with the reason in note_error; retrying would add the note again.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + note: Property.LongText({ + displayName: 'Note', + description: 'Optional note to add to the alert at the same time.', + required: false, + }), + user: Property.ShortText({ + displayName: 'Done By', + description: 'API key connections only: the name shown for this change.', + required: false, + advanced: true, + }), + source: Property.ShortText({ + displayName: 'Source', + description: 'API key connections only: where the change came from.', + required: false, + advanced: true, + }), + }, + outputSchema: alertActionOutputSchema, + async run(context) { + const { alert, identifierType, note, user, source } = context.propsValue; + return jsmOps.sendAlertAction({ + auth: context.auth, + alert, + identifierType: identifierType === 'alias' ? 'alias' : 'id', + action: 'acknowledge', + note, + user, + source, + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/add-note.ts b/packages/pieces/community/jsm-operations/src/lib/actions/add-note.ts new file mode 100644 index 000000000000..f7db86d8ac00 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/add-note.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { addNoteOutputSchema } from '../output-schemas'; + +export const addNoteAction = createAction({ + auth: jsmOpsAuth, + name: 'add_note', + classification: 'WRITE', + displayName: 'Add Note to Alert', + description: 'Add a note to an alert.', + audience: 'both', + aiMetadata: { + description: + 'Adds a text note to a JSM Operations or Opsgenie alert by ID or alias, for status updates or findings. Works with both connection types. Each call adds another note, so retries duplicate it.', + idempotent: false, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + note: Property.LongText({ + displayName: 'Note', + description: 'The note text. Up to 25,000 characters.', + required: true, + }), + user: Property.ShortText({ + displayName: 'Added By', + description: 'API key connections only: the name shown on the note.', + required: false, + advanced: true, + }), + source: Property.ShortText({ + displayName: 'Source', + description: 'API key connections only: where the note came from.', + required: false, + advanced: true, + }), + }, + outputSchema: addNoteOutputSchema, + async run(context) { + const { alert, identifierType, note, user, source } = context.propsValue; + return jsmOps.addNote({ + auth: context.auth, + alert, + identifierType: identifierType === 'alias' ? 'alias' : 'id', + note, + user, + source, + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/add-responder.ts b/packages/pieces/community/jsm-operations/src/lib/actions/add-responder.ts new file mode 100644 index 000000000000..70ea260ada75 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/add-responder.ts @@ -0,0 +1,44 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const addResponderAction = createAction({ + auth: jsmOpsAuth, + name: 'add_responder', + classification: 'WRITE', + displayName: 'Add Responder to Alert', + description: 'Add a team, user, schedule or escalation as a responder.', + audience: 'both', + aiMetadata: { + description: + 'Adds one responder (team, user, schedule or escalation) to a JSM Operations alert so they are notified. Use Assign Alert to change the owner instead. Needs an Atlassian account connection. Adding an existing responder has no further effect, so it is safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + responderType: jsmOpsProps.responderType(), + responder: jsmOpsProps.responder(), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + const { responderType, responder } = context.propsValue; + const id = responder?.trim() ?? ''; + if (id.length === 0) { + throw new Error('Pick the responder to add.'); + } + return jsmOps.runAccountAlertRequest({ + auth: context.auth, + feature: 'Add Responder to Alert', + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + method: HttpMethod.POST, + suffix: 'responders', + body: { id, type: responderType }, + verb: 'add the responder', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/add-tags.ts b/packages/pieces/community/jsm-operations/src/lib/actions/add-tags.ts new file mode 100644 index 000000000000..1416c261486b --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/add-tags.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const addTagsAction = createAction({ + auth: jsmOpsAuth, + name: 'add_tags', + classification: 'WRITE', + displayName: 'Add Tags to Alert', + description: 'Add one or more tags to an alert.', + audience: 'both', + aiMetadata: { + description: + 'Adds tags to a JSM Operations alert by ID or alias, keeping its existing tags. Use Remove Tags from Alert to take tags off. Needs an Atlassian account connection. Adding a tag that is already there has no effect, so it is safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + tags: Property.Array({ + displayName: 'Tags', + description: 'Tags to add to the alert.', + required: true, + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + return jsmOps.changeTags({ + auth: context.auth, + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + tags: context.propsValue.tags, + operation: 'add', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/assign-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/assign-alert.ts new file mode 100644 index 000000000000..5be8ecc5094e --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/assign-alert.ts @@ -0,0 +1,45 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const assignAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'assign_alert', + classification: 'WRITE', + displayName: 'Assign Alert', + description: 'Make a user the owner of an alert. Not available on the JSM Free plan.', + audience: 'both', + aiMetadata: { + description: + 'Sets the owner of a JSM Operations alert to one Atlassian user, picked by account. Use Add Responder to Alert to notify more people without changing the owner. Needs an Atlassian account connection and a paid JSM plan (the Free plan rejects assignment). Safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + accountId: jsmOpsProps.user({ + displayName: 'Assignee', + description: 'The user who will own the alert. Type to search.', + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + const accountId = context.propsValue.accountId?.trim() ?? ''; + if (accountId.length === 0) { + throw new Error('Pick the user to assign the alert to.'); + } + return jsmOps.runAccountAlertRequest({ + auth: context.auth, + feature: 'Assign Alert', + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + method: HttpMethod.POST, + suffix: 'assign', + body: { accountId }, + verb: 'assign the alert', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/close-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/close-alert.ts new file mode 100644 index 000000000000..7469ee891777 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/close-alert.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { alertActionOutputSchema } from '../output-schemas'; + +export const closeAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'close_alert', + classification: 'DESTRUCTIVE', + displayName: 'Close Alert', + description: 'Close an alert once the problem is resolved.', + audience: 'both', + aiMetadata: { + description: + 'Closes a JSM Operations or Opsgenie alert by ID or alias, optionally adding a note, and waits briefly for the result. Works with both connection types. A closed alert cannot be reopened; closing an already closed alert has no further effect, so it is safe to retry. If the optional note cannot be added, the alert is still closed and note_added is false with the reason in note_error; retrying would add the note again.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + note: Property.LongText({ + displayName: 'Note', + description: 'Optional note to add to the alert at the same time.', + required: false, + }), + user: Property.ShortText({ + displayName: 'Done By', + description: 'API key connections only: the name shown for this change.', + required: false, + advanced: true, + }), + source: Property.ShortText({ + displayName: 'Source', + description: 'API key connections only: where the change came from.', + required: false, + advanced: true, + }), + }, + outputSchema: alertActionOutputSchema, + async run(context) { + const { alert, identifierType, note, user, source } = context.propsValue; + return jsmOps.sendAlertAction({ + auth: context.auth, + alert, + identifierType: identifierType === 'alias' ? 'alias' : 'id', + action: 'close', + note, + user, + source, + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/create-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/create-alert.ts new file mode 100644 index 000000000000..e8ea1d73e534 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/create-alert.ts @@ -0,0 +1,210 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { ApiResponder } from '../common/types'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const createAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'create_alert', + classification: 'WRITE', + displayName: 'Create Alert', + description: 'Create a new alert and route it to teams, users or schedules.', + audience: 'both', + aiMetadata: { + description: + 'Creates a JSM Operations (or Opsgenie) alert and waits up to about 10 seconds for it to be processed, returning the new alert ID. Works with both connection types. Set an alias to deduplicate: a second alert with an open alias only raises its count, so retries with the same alias are safe; without an alias each call creates a new alert.', + idempotent: false, + }, + props: { + message: Property.ShortText({ + displayName: 'Message', + description: 'Short summary of the problem. Up to 130 characters.', + placeholder: 'e.g. CPU above 90% on web-01', + required: true, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Full details of the alert. Up to 15,000 characters.', + required: false, + }), + priority: jsmOpsProps.priority({ required: false, defaultValue: 'P3' }), + alias: Property.ShortText({ + displayName: 'Alias', + description: 'Your own unique key for the alert. Open duplicates are merged.', + placeholder: 'e.g. web-01-high-cpu', + required: false, + }), + responderTeams: jsmOpsProps.teams({ + displayName: 'Responder Teams', + description: 'Teams to notify. Needs an Atlassian Account connection.', + }), + responderUsers: jsmOpsProps.users({ + displayName: 'Responder Users', + description: 'Users to notify. Needs an Atlassian Account connection.', + }), + responderSchedules: jsmOpsProps.schedules({ + displayName: 'Responder Schedules', + description: 'Schedules whose on-call users are notified.', + }), + responderEscalations: jsmOpsProps.escalations({ + displayName: 'Responder Escalations', + description: 'Escalation policies to start for this alert.', + }), + responderNames: Property.Array({ + displayName: 'Responders by Name (API Key Connections)', + description: 'For API key connections: responders by team name or user email.', + required: false, + advanced: true, + properties: { + type: Property.StaticDropdown({ + displayName: 'Type', + required: true, + defaultValue: 'team', + options: { + options: [ + { label: 'Team', value: 'team' }, + { label: 'User (email)', value: 'user' }, + { label: 'Schedule', value: 'schedule' }, + { label: 'Escalation', value: 'escalation' }, + ], + }, + }), + name: Property.ShortText({ + displayName: 'Name or Email', + required: true, + }), + }, + }), + tags: Property.Array({ + displayName: 'Tags', + description: 'Labels for searching and grouping alerts.', + required: false, + }), + entity: Property.ShortText({ + displayName: 'Entity', + description: 'The server, service or app the alert is about.', + placeholder: 'e.g. web-01', + required: false, + advanced: true, + }), + source: Property.ShortText({ + displayName: 'Source', + description: 'The tool that raised the alert.', + placeholder: 'e.g. Activepieces', + required: false, + advanced: true, + }), + note: Property.LongText({ + displayName: 'Note', + description: 'A first note to attach to the alert.', + required: false, + advanced: true, + }), + extraProperties: Property.Object({ + displayName: 'Extra Properties', + description: 'Custom key-value details shown on the alert.', + required: false, + advanced: true, + }), + actions: Property.Array({ + displayName: 'Custom Actions', + description: 'Names of custom alert actions to show on the alert.', + required: false, + advanced: true, + }), + user: Property.ShortText({ + displayName: 'Created By', + description: 'API key connections only: who is shown as the creator.', + required: false, + advanced: true, + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + const props = context.propsValue; + const route = await jsmOps.routeFor(context.auth); + const message = props.message.trim(); + if (message.length === 0) { + throw new Error('Enter an alert message.'); + } + const namedResponders = toNamedResponders(props.responderNames); + if (route.kind === 'account' && namedResponders.length > 0) { + throw new Error( + 'Responders by Name only work with API key connections. Pick the responders from the Teams, Users, Schedules or Escalations lists instead.', + ); + } + const responders = [ + ...toIdResponders({ ids: props.responderTeams, type: 'team' }), + ...toIdResponders({ ids: props.responderUsers, type: 'user' }), + ...toIdResponders({ ids: props.responderSchedules, type: 'schedule' }), + ...toIdResponders({ ids: props.responderEscalations, type: 'escalation' }), + ...namedResponders, + ]; + const details = toStringMap(props.extraProperties); + const body = { + message, + ...optional({ key: 'alias', value: props.alias }), + ...optional({ key: 'description', value: props.description }), + ...optional({ key: 'priority', value: props.priority }), + ...optional({ key: 'entity', value: props.entity }), + ...optional({ key: 'source', value: props.source }), + ...optional({ key: 'note', value: props.note }), + ...(route.kind === 'key' ? optional({ key: 'user', value: props.user }) : {}), + ...(responders.length > 0 ? { responders } : {}), + ...listField({ key: 'tags', value: props.tags }), + ...listField({ key: 'actions', value: props.actions }), + ...(Object.keys(details).length > 0 ? { [route.kind === 'key' ? 'details' : 'extraProperties']: details } : {}), + }; + return jsmOps.runAsync({ route, method: HttpMethod.POST, path: '/alerts', body, verb: 'create the alert' }); + }, +}); + +function toIdResponders({ ids, type }: { ids: unknown; type: string }): ApiResponder[] { + return jsmOps.parseStringList(ids).map((id) => ({ id, type })); +} + +function toNamedResponders(rows: unknown): ApiResponder[] { + if (!Array.isArray(rows)) { + return []; + } + return rows.flatMap((row) => { + if (row === null || typeof row !== 'object' || !('type' in row) || !('name' in row)) { + return []; + } + const type = typeof row.type === 'string' ? row.type : ''; + const name = typeof row.name === 'string' ? row.name.trim() : ''; + if (name.length === 0 || !RESPONDER_TYPES.includes(type)) { + return []; + } + return [type === 'user' ? { username: name, type } : { name, type }]; + }); +} + +function toStringMap(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + if (key.trim().length === 0 || item === undefined || item === null || item === '') { + return []; + } + return [[key, typeof item === 'string' ? item : JSON.stringify(item)]]; + }), + ); +} + +function optional({ key, value }: { key: string; value: string | undefined }): Record { + const trimmed = value?.trim() ?? ''; + return trimmed.length > 0 ? { [key]: trimmed } : {}; +} + +function listField({ key, value }: { key: string; value: unknown }): Record { + const list = jsmOps.parseStringList(value); + return list.length > 0 ? { [key]: list } : {}; +} + +const RESPONDER_TYPES = ['team', 'user', 'schedule', 'escalation']; diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/custom-api-call.ts b/packages/pieces/community/jsm-operations/src/lib/actions/custom-api-call.ts new file mode 100644 index 000000000000..d17901b00d70 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/custom-api-call.ts @@ -0,0 +1,84 @@ +import { createAction, IAction, InputPropertyMap } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { JsmAuthValue } from '../common/types'; + +export const jsmCustomApiCall = { + baseUrl, + authMapping, + withCloudId, +}; + +function baseUrl(auth: JsmAuthValue | undefined): string { + if (auth === undefined) { + return ''; + } + if (jsmOps.isKeyConnection(auth)) { + return jsmOps.keyBaseUrl(jsmOps.keyProps(auth)); + } + const cloudId = auth.props.cloudId?.trim() ?? ''; + return jsmOps.accountBaseUrl(cloudId.length > 0 ? cloudId : '{cloudId}'); +} + +async function authMapping(auth: JsmAuthValue): Promise> { + return jsmOps.authHeaders(auth); +} + +function withCloudId(base: IAction) { + return createAction({ + auth: jsmOpsAuth, + name: 'custom_api_call', + classification: 'WRITE', + displayName: base.displayName, + description: 'Call any JSM Operations endpoint with this connection.', + audience: 'human', + aiMetadata: { + description: + 'Sends a raw HTTP request to the JSM Operations API with the connection credentials: /jsm/ops/api/{cloudId}/v1 for account connections, the /v2 alert API for API keys. Use only when no dedicated action fits. Whether a retry is safe depends on the method and endpoint.', + idempotent: false, + }, + props: base.props, + errorHandlingOptions: base.errorHandlingOptions, + async run(context) { + const auth = context.auth; + const url = readUrl(context.propsValue); + if (jsmOps.isKeyConnection(auth)) { + assertOnConnectionHost({ url, base: baseUrl(auth) }); + return base.run({ ...context, auth }); + } + const props = await jsmOps.fillCloudId(auth.props); + const filled = { ...auth, props }; + assertOnConnectionHost({ url, base: baseUrl(filled) }); + return base.run({ ...context, auth: filled }); + }, + }); +} + +function readUrl(propsValue: unknown): string { + if (typeof propsValue !== 'object' || propsValue === null || !('url' in propsValue)) { + return ''; + } + const field = propsValue.url; + if (typeof field !== 'object' || field === null || !('url' in field)) { + return ''; + } + return typeof field.url === 'string' ? field.url.trim() : ''; +} + +function assertOnConnectionHost({ url, base }: { url: string; base: string }): void { + if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//')) { + return; + } + const allowed = new URL(base); + const allowedPath = allowed.pathname.replace(/\/+$/, ''); + let target: URL; + try { + target = new URL(url); + } catch { + throw new Error(`"${url}" is not a valid URL. Use a path such as /alerts, or a full URL under ${base}.`); + } + const onBase = target.origin === allowed.origin && (target.pathname === allowedPath || target.pathname.startsWith(`${allowedPath}/`)); + if (!onBase || target.username !== '' || target.password !== '') { + throw new Error(`Custom API Call only sends this connection's credentials to ${base}. Use a path such as /alerts, or a full URL under that address.`); + } +} diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/escalate-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/escalate-alert.ts new file mode 100644 index 000000000000..6d2870a50667 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/escalate-alert.ts @@ -0,0 +1,45 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const escalateAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'escalate_alert', + classification: 'WRITE', + displayName: 'Escalate Alert', + description: 'Escalate an alert to the next level of an escalation policy.', + audience: 'both', + aiMetadata: { + description: + 'Escalates a JSM Operations alert through the chosen escalation policy, notifying its next recipients. Use Add Responder to Alert to notify one specific team or user. Needs an Atlassian account connection. Each call can notify further recipients, so retries are not safe.', + idempotent: false, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + escalationId: jsmOpsProps.escalation({ + displayName: 'Escalation', + description: 'The escalation policy to use, listed with its team.', + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + const escalationId = context.propsValue.escalationId?.trim() ?? ''; + if (escalationId.length === 0) { + throw new Error('Pick the escalation to use.'); + } + return jsmOps.runAccountAlertRequest({ + auth: context.auth, + feature: 'Escalate Alert', + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + method: HttpMethod.POST, + suffix: 'escalate', + body: { escalationId }, + verb: 'escalate the alert', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/find-alerts.ts b/packages/pieces/community/jsm-operations/src/lib/actions/find-alerts.ts new file mode 100644 index 000000000000..303ba458a82e --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/find-alerts.ts @@ -0,0 +1,110 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { ApiAlertList } from '../common/types'; +import { findAlertsOutputSchema } from '../output-schemas'; + +export const findAlertsAction = createAction({ + auth: jsmOpsAuth, + name: 'find_alerts', + classification: 'SEARCH', + displayName: 'Find Alerts', + description: 'Search alerts with a JSM Operations search query.', + audience: 'both', + aiMetadata: { + description: + 'Lists JSM Operations alerts that match an alert search query (for example status: open AND priority: P1), sorted and paged. Returns zero or more alerts. Use Get Alert when you already know the ID or alias. Needs an Atlassian account connection. Safe to retry.', + idempotent: true, + }, + props: { + query: Property.ShortText({ + displayName: 'Search Query', + description: 'Alert search syntax. Leave empty to list all alerts.', + placeholder: 'e.g. status: open AND priority: P1', + required: false, + }), + sort: Property.StaticDropdown({ + displayName: 'Sort By', + required: false, + defaultValue: 'createdAt', + options: { + options: [ + { label: 'Created at', value: 'createdAt' }, + { label: 'Last occurred at', value: 'lastOccurredAt' }, + { label: 'Inserted at', value: 'insertedAt' }, + { label: 'Priority', value: 'priority' }, + ], + }, + }), + order: Property.StaticDropdown({ + displayName: 'Order', + required: false, + defaultValue: 'desc', + options: { + options: [ + { label: 'Newest / highest first', value: 'desc' }, + { label: 'Oldest / lowest first', value: 'asc' }, + ], + }, + }), + limit: Property.Number({ + displayName: 'Max Results', + description: 'How many alerts to return, from 1 to 100.', + required: false, + defaultValue: 20, + display: 'stepper', + min: 1, + max: 100, + step: 1, + }), + offset: Property.Number({ + displayName: 'Skip', + description: 'How many matching alerts to skip, for paging.', + required: false, + defaultValue: 0, + advanced: true, + }), + }, + outputSchema: findAlertsOutputSchema, + async run(context) { + const route = await jsmOps.requireAccountRoute({ auth: context.auth, feature: 'Find Alerts' }); + const body = await jsmOps.send({ + route, + method: HttpMethod.GET, + path: '/alerts', + queryParams: buildFindQuery(context.propsValue), + }); + return (body.values ?? []).map((alert) => jsmOps.toAlertOutput(alert)); + }, +}); + +function buildFindQuery({ query, sort, order, limit, offset }: FindInput): Record { + const size = clamp({ value: limit ?? 20, min: 1, max: 100 }); + const skip = Math.max(0, Math.floor(offset ?? 0)); + const trimmed = query?.trim() ?? ''; + return { + ...(trimmed.length > 0 ? { query: trimmed } : {}), + sort: SORT_FIELDS.includes(sort ?? '') ? sort ?? 'createdAt' : 'createdAt', + order: order === 'asc' ? 'asc' : 'desc', + size: String(size), + offset: String(skip), + }; +} + +function clamp({ value, min, max }: { value: number; min: number; max: number }): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(max, Math.max(min, Math.floor(value))); +} + +const SORT_FIELDS = ['createdAt', 'lastOccurredAt', 'insertedAt', 'priority']; + +type FindInput = { + query?: string; + sort?: string; + order?: string; + limit?: number; + offset?: number; +}; diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/get-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/get-alert.ts new file mode 100644 index 000000000000..e70bf2767da9 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/get-alert.ts @@ -0,0 +1,33 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { alertOutputSchema } from '../output-schemas'; + +export const getAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'get_alert', + classification: 'READ', + displayName: 'Get Alert', + description: 'Get one alert by its ID or alias.', + audience: 'both', + aiMetadata: { + description: + 'Reads a single JSM Operations alert by its alert ID or by its alias and returns its status, priority, owner, tags and timestamps. Use Find Alerts to search by criteria instead. Needs an Atlassian account connection. Safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + }, + outputSchema: alertOutputSchema, + async run(context) { + const route = await jsmOps.requireAccountRoute({ auth: context.auth, feature: 'Get Alert' }); + const alert = await jsmOps.getAlert({ + route, + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + }); + return jsmOps.toAlertOutput(alert); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/get-on-call.ts b/packages/pieces/community/jsm-operations/src/lib/actions/get-on-call.ts new file mode 100644 index 000000000000..cf15ee956d41 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/get-on-call.ts @@ -0,0 +1,91 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { ApiOnCallParticipant, ApiOnCallResponse, OnCallParticipantOutput } from '../common/types'; +import { onCallOutputSchema } from '../output-schemas'; + +export const getOnCallAction = createAction({ + auth: jsmOpsAuth, + name: 'get_on_call', + classification: 'READ', + displayName: 'Get Who Is On Call', + description: 'Get who is on call for a schedule, now or at a given time.', + audience: 'both', + aiMetadata: { + description: + 'Returns the on-call participants of one JSM Operations schedule at the current time or a given date, including users reached through teams or escalations, plus a comma-separated list of on-call user account IDs. Needs an Atlassian account connection. Safe to retry.', + idempotent: true, + }, + props: { + scheduleId: jsmOpsProps.schedule({ + displayName: 'Schedule', + description: 'The on-call schedule to check.', + }), + date: Property.DateTime({ + displayName: 'At Time', + description: 'Leave empty to check who is on call right now.', + required: false, + }), + }, + outputSchema: onCallOutputSchema, + async run(context) { + const scheduleId = context.propsValue.scheduleId?.trim() ?? ''; + if (scheduleId.length === 0) { + throw new Error('Pick the schedule to check.'); + } + const date = toIsoDate(context.propsValue.date); + const route = await jsmOps.requireAccountRoute({ auth: context.auth, feature: 'Get Who Is On Call' }); + const body = await jsmOps.send({ + route, + method: HttpMethod.GET, + path: `/schedules/${encodeURIComponent(scheduleId)}/on-calls`, + queryParams: { flat: 'false', ...(date === null ? {} : { date }) }, + }); + const participants = flattenParticipants({ participants: body.onCallParticipants ?? [], parent: null }); + const userIds = [ + ...new Set([ + ...participants.flatMap((participant) => (participant.type === 'user' && participant.id !== null ? [participant.id] : [])), + ...(body.onCallUsers ?? []), + ]), + ]; + return { + schedule_id: scheduleId, + date, + on_call_user_ids: userIds.join(', '), + on_call_user_count: userIds.length, + participants, + }; + }, +}); + +function flattenParticipants({ participants, parent }: FlattenParams): OnCallParticipantOutput[] { + return participants.flatMap((participant) => { + const row: OnCallParticipantOutput = { + id: participant.id ?? null, + type: participant.type ?? null, + name: participant.name ?? null, + parent_id: parent?.id ?? null, + parent_type: parent?.type ?? null, + forwarded_from_id: participant.forwardedFrom?.id ?? null, + }; + return [row, ...flattenParticipants({ participants: participant.onCallParticipants ?? [], parent: participant })]; + }); +} + +function toIsoDate(value: string | undefined): string | null { + if (value === undefined || value.trim().length === 0) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error('At Time must be a valid date and time.'); + } + return parsed.toISOString(); +} + +type FlattenParams = { + participants: ApiOnCallParticipant[]; + parent: ApiOnCallParticipant | null; +}; diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/remove-tags.ts b/packages/pieces/community/jsm-operations/src/lib/actions/remove-tags.ts new file mode 100644 index 000000000000..05a4bbbebd1f --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/remove-tags.ts @@ -0,0 +1,38 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const removeTagsAction = createAction({ + auth: jsmOpsAuth, + name: 'remove_tags', + classification: 'WRITE', + displayName: 'Remove Tags from Alert', + description: 'Remove one or more tags from an alert.', + audience: 'both', + aiMetadata: { + description: + 'Removes the listed tags from a JSM Operations alert by ID or alias; the alert itself is kept. Use Add Tags to Alert to add them back. Needs an Atlassian account connection. Safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + tags: Property.Array({ + displayName: 'Tags', + description: 'Tags to remove from the alert.', + required: true, + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + return jsmOps.changeTags({ + auth: context.auth, + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + tags: context.propsValue.tags, + operation: 'remove', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/snooze-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/snooze-alert.ts new file mode 100644 index 000000000000..0151076c7c7d --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/snooze-alert.ts @@ -0,0 +1,49 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const snoozeAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'snooze_alert', + classification: 'WRITE', + displayName: 'Snooze Alert', + description: 'Pause notifications for an alert until a set time. Not on JSM Free.', + audience: 'both', + aiMetadata: { + description: + 'Snoozes a JSM Operations alert until the given end time, pausing its notifications; the alert reopens after that. Needs an Atlassian account connection and a paid JSM plan (the Free plan rejects snoozing). Snoozing again to the same time has the same result, so it is safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + endTime: Property.DateTime({ + displayName: 'Snooze Until', + description: 'When notifications should start again. Must be in the future.', + required: true, + }), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + const endTime = new Date(context.propsValue.endTime); + if (Number.isNaN(endTime.getTime())) { + throw new Error('Snooze Until must be a valid date and time.'); + } + if (endTime.getTime() <= Date.now()) { + throw new Error('Snooze Until must be in the future.'); + } + return jsmOps.runAccountAlertRequest({ + auth: context.auth, + feature: 'Snooze Alert', + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + method: HttpMethod.POST, + suffix: 'snooze', + body: { endTime: endTime.toISOString() }, + verb: 'snooze the alert', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/unacknowledge-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/unacknowledge-alert.ts new file mode 100644 index 000000000000..0c8fb836e232 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/unacknowledge-alert.ts @@ -0,0 +1,36 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { requestResultOutputSchema } from '../output-schemas'; + +export const unacknowledgeAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'unacknowledge_alert', + classification: 'WRITE', + displayName: 'Unacknowledge Alert', + description: 'Put an acknowledged alert back to open.', + audience: 'both', + aiMetadata: { + description: + 'Reverts an acknowledged JSM Operations alert to unacknowledged so notifications resume. Use Acknowledge Alert for the opposite. Needs an Atlassian account connection. Safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + }, + outputSchema: requestResultOutputSchema, + async run(context) { + return jsmOps.runAccountAlertRequest({ + auth: context.auth, + feature: 'Unacknowledge Alert', + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + method: HttpMethod.POST, + suffix: 'unacknowledge', + verb: 'unacknowledge the alert', + }); + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/actions/update-alert.ts b/packages/pieces/community/jsm-operations/src/lib/actions/update-alert.ts new file mode 100644 index 000000000000..46651dbe525e --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/actions/update-alert.ts @@ -0,0 +1,71 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { jsmOpsProps } from '../common/props'; +import { RequestResult } from '../common/types'; +import { updateAlertOutputSchema } from '../output-schemas'; + +export const updateAlertAction = createAction({ + auth: jsmOpsAuth, + name: 'update_alert', + classification: 'WRITE', + displayName: 'Update Alert', + description: 'Change the message, description or priority of an alert.', + audience: 'both', + aiMetadata: { + description: + 'Updates the message, description and/or priority of an existing JSM Operations alert; only the fields you fill in are changed. Needs an Atlassian account connection. Setting the same values again gives the same result, so it is safe to retry.', + idempotent: true, + }, + props: { + alert: jsmOpsProps.alert(), + identifierType: jsmOpsProps.identifierType(), + message: Property.ShortText({ + displayName: 'New Message', + description: 'Leave empty to keep the current message. Up to 130 characters.', + required: false, + }), + description: Property.LongText({ + displayName: 'New Description', + description: 'Leave empty to keep the current description.', + required: false, + }), + priority: jsmOpsProps.priority({ required: false }), + }, + outputSchema: updateAlertOutputSchema, + async run(context) { + const { message, description, priority } = context.propsValue; + const changes = [ + { field: 'message', value: message?.trim() ?? '' }, + { field: 'description', value: description?.trim() ?? '' }, + { field: 'priority', value: priority ?? '' }, + ].filter((change) => change.value.length > 0); + if (changes.length === 0) { + throw new Error('Fill in at least one of New Message, New Description or Priority.'); + } + const route = await jsmOps.requireAccountRoute({ auth: context.auth, feature: 'Update Alert' }); + const alertId = await jsmOps.resolveAlertId({ + route, + alert: context.propsValue.alert, + identifierType: context.propsValue.identifierType === 'alias' ? 'alias' : 'id', + }); + const requests: (RequestResult & { field: string })[] = []; + for (const change of changes) { + const result = await jsmOps.runAsync({ + route, + method: HttpMethod.PATCH, + path: `/alerts/${encodeURIComponent(alertId)}/${change.field}`, + body: { [change.field]: change.value }, + verb: `update the alert ${change.field}`, + }); + requests.push({ field: change.field, ...result, alert_id: result.alert_id ?? alertId }); + } + return { + alert_id: alertId, + updated_fields: changes.map((change) => change.field).join(', '), + processed: requests.every((request) => request.processed), + requests, + }; + }, +}); diff --git a/packages/pieces/community/jsm-operations/src/lib/auth.ts b/packages/pieces/community/jsm-operations/src/lib/auth.ts new file mode 100644 index 000000000000..b0e42d491703 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/auth.ts @@ -0,0 +1,59 @@ +import { PieceAuth, Property } from '@activepieces/pieces-framework'; +import { jsmOps } from './common/client'; + +export const jsmAccountAuth = PieceAuth.CustomAuth({ + displayName: 'Atlassian Account', + description: `Use this for every action. It connects with your Atlassian email and an API token. + +1. Open [API tokens](https://id.atlassian.com/manage-profile/security/api-tokens) and click **Create API token** (not "with scopes"). +2. Copy the token and paste it below with the email you sign in with. +3. Enter your site URL, like https://your-company.atlassian.net. + +The account needs access to JSM Operations (alerts, teams and schedules) on that site.`, + required: true, + props: { + siteUrl: Property.ShortText({ + displayName: 'Site URL', + description: 'Your Atlassian Cloud site address.', + placeholder: 'https://your-company.atlassian.net', + required: true, + }), + email: Property.ShortText({ + displayName: 'Email', + description: 'The email address of the Atlassian account.', + placeholder: 'you@example.com', + required: true, + }), + apiToken: PieceAuth.SecretText({ + displayName: 'API Token', + description: 'An unscoped API token from id.atlassian.com.', + required: true, + }), + cloudId: Property.ShortText({ + displayName: 'Cloud ID', + description: 'Leave empty to look it up from the site URL.', + required: false, + }), + }, + validate: async ({ auth }) => jsmOps.validateAccount(auth), +}); + +export const jsmKeyAuth = PieceAuth.BasicAuth({ + displayName: 'Alert API Key (Opsgenie or JSM integration)', + description: `Use this to create, acknowledge and close alerts or add notes with an integration API key. Other actions and the trigger need an Atlassian Account connection. + +- **JSM Operations:** open **Operations > Integrations**, add an **API** integration, copy its API key, and enter \`jsm\` as the API host. +- **Opsgenie:** open **Settings > Integrations**, add an **API** integration, and copy its API key. Enter \`us\` as the API host, or \`eu\` if your Opsgenie address contains "eu".`, + required: true, + username: { + displayName: 'API Host', + description: 'jsm (JSM integration), us (Opsgenie US) or eu (Opsgenie EU).', + }, + password: { + displayName: 'API Key', + description: 'The API key of an API integration.', + }, + validate: async ({ auth }) => jsmOps.validateKey(auth), +}); + +export const jsmOpsAuth = [jsmAccountAuth, jsmKeyAuth]; diff --git a/packages/pieces/community/jsm-operations/src/lib/common/client.ts b/packages/pieces/community/jsm-operations/src/lib/common/client.ts new file mode 100644 index 000000000000..9a90861fbfaa --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/common/client.ts @@ -0,0 +1,678 @@ +import { httpClient, HttpError, HttpMethod } from '@activepieces/pieces-common'; +import { AppConnectionType } from '@activepieces/pieces-framework'; +import { + AccountAuthProps, + AccountRoute, + AlertActionResult, + AlertIdentifierType, + AlertOutput, + ApiAlert, + ApiAsyncResponse, + ApiNote, + ApiRequestStatus, + JsmAuthValue, + KeyAuthProps, + KeyRoute, + RequestResult, + Route, +} from './types'; + +export const jsmOps = { + isKeyConnection, + keyProps, + resolveKeyHost, + normalizeSiteUrl, + resolveCloudId, + fillCloudId, + accountBaseUrl, + keyBaseUrl, + authHeaders, + routeFor, + requireAccountRoute, + send, + sendToSite, + resolveAlertId, + getAlert, + runAsync, + awaitRequest, + sendAlertAction, + runAccountAlertRequest, + changeTags, + addNote, + toAlertOutput, + parseStringList, + describeError, + validateAccount, + validateKey, +}; + +export const JSM_ACCOUNT_API_URL = 'https://api.atlassian.com/jsm/ops/api'; +export const KEY_HOSTS = { + jsm: 'https://api.atlassian.com/jsm/ops/integration', + opsgenieUs: 'https://api.opsgenie.com', + opsgenieEu: 'https://api.eu.opsgenie.com', +}; +export const REQUEST_TIMEOUT_MS = 15_000; +export const POLL_TIMEOUT_MS = 4_000; +export const POLL_DELAYS_MS = [500, 1_000, 2_000, 3_000, 4_000]; + +function isKeyConnection(auth: T): auth is Extract { + return auth.type === AppConnectionType.BASIC_AUTH; +} + +function keyProps({ username, password }: { username: string; password: string }): KeyAuthProps { + return { host: resolveKeyHost(username), apiKey: password.trim() }; +} + +function resolveKeyHost(value: string): string { + const normalized = value.trim().toLowerCase().replace(/\/+$/, ''); + const alias = KEY_HOST_ALIASES[normalized]; + if (alias !== undefined) { + return alias; + } + const withScheme = normalized.startsWith('https://') ? normalized : `https://${normalized}`; + const host = Object.values(KEY_HOSTS).find((allowed) => allowed === withScheme); + if (host === undefined) { + throw new Error('Unknown API host. Edit the connection and enter jsm, us or eu as the API host.'); + } + return host; +} + +function normalizeSiteUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ''); + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + throw new Error(`"${raw}" is not a valid Atlassian site URL. Use the form https://your-company.atlassian.net.`); + } + const host = parsed.hostname.toLowerCase(); + const allowedHost = ALLOWED_SITE_SUFFIXES.some((suffix) => host.endsWith(suffix) && host.length > suffix.length); + if (parsed.protocol !== 'https:' || !allowedHost || parsed.port !== '' || parsed.username !== '' || parsed.password !== '') { + throw new Error(`"${raw}" is not an Atlassian Cloud site. Use the form https://your-company.atlassian.net.`); + } + return `https://${host}`; +} + +async function resolveCloudId(props: AccountAuthProps): Promise { + const configured = props.cloudId?.trim(); + if (configured !== undefined && configured.length > 0) { + if (!CLOUD_ID_PATTERN.test(configured)) { + throw new Error('The Cloud ID can only contain letters, numbers and dashes. Leave it empty to look it up automatically.'); + } + return configured; + } + const siteUrl = normalizeSiteUrl(props.siteUrl); + let body: { cloudId?: unknown }; + try { + const response = await httpClient.sendRequest<{ cloudId?: unknown }>({ + method: HttpMethod.GET, + url: `${siteUrl}/_edge/tenant_info`, + followRedirects: false, + timeout: REQUEST_TIMEOUT_MS, + }); + body = response.body; + } catch (error) { + throw new Error(`Could not look up the Cloud ID for ${siteUrl}: ${describeError(error)}. Check the site URL, or enter the Cloud ID.`); + } + const cloudId = typeof body?.cloudId === 'string' ? body.cloudId : ''; + if (!CLOUD_ID_PATTERN.test(cloudId)) { + throw new Error(`${siteUrl} did not return a Cloud ID. Check the site URL, or enter the Cloud ID.`); + } + return cloudId; +} + +async function fillCloudId

(props: P): Promise

{ + const cloudId = await resolveCloudId(props); + return { ...props, cloudId }; +} + +function accountBaseUrl(cloudId: string): string { + return `${JSM_ACCOUNT_API_URL}/${encodeURIComponent(cloudId)}/v1`; +} + +function keyBaseUrl(props: KeyAuthProps): string { + return `${resolveKeyHost(props.host)}/v2`; +} + +function authHeaders(auth: JsmAuthValue): Record { + if (isKeyConnection(auth)) { + return { Authorization: `GenieKey ${keyProps(auth).apiKey}` }; + } + const token = Buffer.from(`${auth.props.email.trim()}:${auth.props.apiToken}`).toString('base64'); + return { Authorization: `Basic ${token}` }; +} + +async function routeFor(auth: JsmAuthValue): Promise { + if (isKeyConnection(auth)) { + const route: KeyRoute = { kind: 'key', baseUrl: keyBaseUrl(keyProps(auth)), headers: authHeaders(auth) }; + return route; + } + const cloudId = await resolveCloudId(auth.props); + return { + kind: 'account', + baseUrl: accountBaseUrl(cloudId), + siteUrl: normalizeSiteUrl(auth.props.siteUrl), + headers: authHeaders(auth), + }; +} + +async function requireAccountRoute({ auth, feature }: { auth: JsmAuthValue; feature: string }): Promise { + if (isKeyConnection(auth)) { + throw new Error( + `${feature} needs an Atlassian account connection. Alert API keys can only create, acknowledge and close alerts or add notes. Create an "Atlassian Account" connection (site URL, email and API token) and use it for this step.`, + ); + } + const route = await routeFor(auth); + if (route.kind !== 'account') { + throw new Error(`${feature} needs an Atlassian account connection.`); + } + return route; +} + +async function send({ route, method, path, queryParams, body, timeout }: SendParams): Promise { + try { + const response = await httpClient.sendRequest({ + method, + url: `${route.baseUrl}${path}`, + headers: { ...route.headers, Accept: 'application/json' }, + queryParams, + body, + followRedirects: false, + timeout: timeout ?? REQUEST_TIMEOUT_MS, + }); + return response.body; + } catch (error) { + throw new Error(describeError(error)); + } +} + +async function sendToSite({ route, path, queryParams }: SiteParams): Promise { + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${route.siteUrl}${path}`, + headers: { ...route.headers, Accept: 'application/json' }, + queryParams, + followRedirects: false, + timeout: REQUEST_TIMEOUT_MS, + }); + return response.body; + } catch (error) { + throw new Error(describeError(error)); + } +} + +async function resolveAlertId({ route, alert, identifierType }: AlertLookup): Promise { + const value = requireIdentifier(alert); + if (identifierType !== 'alias') { + return value; + } + const found = await send({ + route, + method: HttpMethod.GET, + path: '/alerts/alias', + queryParams: { alias: value }, + }); + if (typeof found.id !== 'string' || found.id.length === 0) { + throw new Error(`No alert was found with the alias "${value}".`); + } + return found.id; +} + +async function getAlert({ route, alert, identifierType }: AlertLookup): Promise { + const value = requireIdentifier(alert); + if (identifierType === 'alias') { + return send({ route, method: HttpMethod.GET, path: '/alerts/alias', queryParams: { alias: value } }); + } + return send({ route, method: HttpMethod.GET, path: `/alerts/${encodeURIComponent(value)}` }); +} + +async function runAsync({ route, method, path, queryParams, body, verb }: AsyncParams): Promise { + const accepted = await send({ route, method, path, queryParams, body }); + const requestId = accepted?.requestId; + if (typeof requestId !== 'string' || requestId.length === 0) { + throw new Error(`JSM Operations accepted the request to ${verb} but did not return a request ID.`); + } + const result = await awaitRequest({ route, requestId }); + if (result.processed && result.success === false) { + throw new Error(requestFailureMessage({ verb, status: result.status })); + } + return result; +} + +async function awaitRequest({ route, requestId }: { route: Route; requestId: string }): Promise { + for (const delay of POLL_DELAYS_MS) { + await sleep(delay); + const status = await fetchRequestStatus({ route, requestId }); + if (status !== null) { + return { + request_id: requestId, + processed: true, + success: status.isSuccess ?? status.success ?? null, + action: status.action ?? null, + status: status.status ?? null, + alert_id: nonEmpty(status.alertId), + alias: nonEmpty(status.alias), + processed_at: status.processedAt ?? null, + }; + } + } + return { + request_id: requestId, + processed: false, + success: null, + action: null, + status: 'Still processing. Look the request up later with its request ID.', + alert_id: null, + alias: null, + processed_at: null, + }; +} + +async function sendAlertAction({ auth, alert, identifierType, action, note, user, source }: AlertActionParams): Promise { + const route = await routeFor(auth); + const verb = action === 'acknowledge' ? 'acknowledge the alert' : 'close the alert'; + const cleanNote = nonEmpty(note); + if (route.kind === 'key') { + const result = await runAsync({ + route, + method: HttpMethod.POST, + path: `/alerts/${encodeURIComponent(requireIdentifier(alert))}/${action}`, + queryParams: { identifierType }, + body: compact({ note: cleanNote, user: nonEmpty(user), source: nonEmpty(source) }), + verb, + }); + return { ...result, note_added: cleanNote === null ? null : result.success, note_error: null }; + } + const alertId = await resolveAlertId({ route, alert, identifierType }); + const result = await runAsync({ + route, + method: HttpMethod.POST, + path: `/alerts/${encodeURIComponent(alertId)}/${action}`, + verb, + }); + const withId = { ...result, alert_id: result.alert_id ?? alertId }; + if (cleanNote === null) { + return { ...withId, note_added: null, note_error: null }; + } + try { + await send({ + route, + method: HttpMethod.POST, + path: `/alerts/${encodeURIComponent(alertId)}/notes`, + body: { note: cleanNote }, + }); + return { ...withId, note_added: true, note_error: null }; + } catch (error) { + return { ...withId, note_added: false, note_error: `The alert was updated, but the note could not be added: ${describeError(error)}` }; + } +} + +async function runAccountAlertRequest({ auth, feature, alert, identifierType, method, suffix, body, verb }: AccountAlertRequestParams): Promise { + const route = await requireAccountRoute({ auth, feature }); + const alertId = await resolveAlertId({ route, alert, identifierType }); + const result = await runAsync({ + route, + method, + path: `/alerts/${encodeURIComponent(alertId)}/${suffix}`, + body, + verb, + }); + return { ...result, alert_id: result.alert_id ?? alertId }; +} + +async function changeTags({ auth, alert, identifierType, tags, operation }: ChangeTagsParams): Promise { + const list = parseStringList(tags); + if (list.length === 0) { + throw new Error('Enter at least one tag.'); + } + return runAccountAlertRequest({ + auth, + feature: operation === 'add' ? 'Add Tags to Alert' : 'Remove Tags from Alert', + alert, + identifierType, + method: operation === 'add' ? HttpMethod.POST : HttpMethod.DELETE, + suffix: 'tags', + body: { tags: list }, + verb: operation === 'add' ? 'add the tags' : 'remove the tags', + }); +} + +async function addNote({ auth, alert, identifierType, note, user, source }: AddNoteParams): Promise { + const route = await routeFor(auth); + const text = nonEmpty(note); + if (text === null) { + throw new Error('Enter the note text.'); + } + if (route.kind === 'key') { + const result = await runAsync({ + route, + method: HttpMethod.POST, + path: `/alerts/${encodeURIComponent(requireIdentifier(alert))}/notes`, + queryParams: { identifierType }, + body: compact({ note: text, user: nonEmpty(user), source: nonEmpty(source) }), + verb: 'add the note', + }); + return { ...result, note_id: null, note: text }; + } + const alertId = await resolveAlertId({ route, alert, identifierType }); + const created = await send({ + route, + method: HttpMethod.POST, + path: `/alerts/${encodeURIComponent(alertId)}/notes`, + body: { note: text }, + }); + return { + request_id: null, + processed: true, + success: true, + action: 'AddNote', + status: 'Note added', + alert_id: alertId, + alias: identifierType === 'alias' ? requireIdentifier(alert) : null, + processed_at: created?.createdAt ?? null, + note_id: nonEmpty(created?.id), + note: created?.note ?? text, + }; +} + +function toAlertOutput(alert: ApiAlert): AlertOutput { + return { + id: nonEmpty(alert.id), + tiny_id: nonEmpty(alert.tinyId), + message: alert.message ?? null, + description: alert.description ?? null, + status: alert.status ?? null, + priority: alert.priority ?? null, + alias: nonEmpty(alert.alias), + entity: nonEmpty(alert.entity), + source: nonEmpty(alert.source), + owner: nonEmpty(alert.owner), + acknowledged: typeof alert.acknowledged === 'boolean' ? alert.acknowledged : null, + seen: typeof alert.seen === 'boolean' ? alert.seen : null, + snoozed: typeof alert.snoozed === 'boolean' ? alert.snoozed : null, + snoozed_until: alert.snoozedUntil ?? null, + count: typeof alert.count === 'number' ? alert.count : null, + tags: joinList(alert.tags), + actions: joinList(alert.actions), + responders: joinList((alert.responders ?? []).map((responder) => `${responder.type}:${responder.id ?? responder.name ?? responder.username ?? ''}`)), + extra_properties: alert.extraProperties ?? {}, + integration_name: nonEmpty(alert.integrationName), + integration_type: nonEmpty(alert.integrationType), + created_at: alert.createdAt ?? null, + updated_at: alert.updatedAt ?? null, + last_occurred_at: alert.lastOccurredAt ?? alert.lastOccuredAt ?? null, + ack_time: alert.ackTime ?? null, + close_time: alert.closeTime ?? null, + }; +} + +function parseStringList(value: unknown): string[] { + const list = typeof value === 'string' ? parseJsonList(value) : value; + if (!Array.isArray(list)) { + return []; + } + return list + .map((item) => (typeof item === 'string' || typeof item === 'number' ? String(item).trim() : '')) + .filter((item) => item.length > 0); +} + +function requestFailureMessage({ verb, status }: { verb: string; status: string | null | undefined }): string { + const reason = (status ?? 'the request failed').replace(/\.+$/, ''); + const planHint = /plan does not support/i.test(reason) + ? ' This action is not included in your Jira Service Management plan (for example, the Free plan). Upgrade the plan or skip this step.' + : ''; + return `JSM Operations could not ${verb}: ${reason}.${planHint}`; +} + +function describeError(error: unknown): string { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : String(error); + } + const status = error.response.status; + const detail = readErrorMessage(error.response.body); + const base = `JSM Operations error (HTTP ${status})${detail.length > 0 ? `: ${detail}` : ''}`; + const hint = STATUS_HINTS[status]; + return hint === undefined ? base : `${base}. ${hint}`; +} + +async function validateAccount(props: AccountAuthProps): Promise { + try { + const route = await routeFor({ type: AppConnectionType.CUSTOM_AUTH, props }); + await send({ route, method: HttpMethod.GET, path: '/alerts', queryParams: { size: '1' } }); + return { valid: true }; + } catch (error) { + return { valid: false, error: describeError(error) }; + } +} + +async function validateKey({ username, password }: { username: string; password: string }): Promise { + let route: Route; + try { + route = await routeFor({ type: AppConnectionType.BASIC_AUTH, username, password }); + } catch (error) { + return { valid: false, error: describeError(error) }; + } + try { + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${route.baseUrl}/alerts/requests/${VALIDATION_REQUEST_ID}`, + headers: route.headers, + followRedirects: false, + timeout: REQUEST_TIMEOUT_MS, + }); + return { valid: true }; + } catch (error) { + const status = error instanceof HttpError ? error.response.status : undefined; + if (status === 404 || status === 422 || status === 400) { + return { valid: true }; + } + return { valid: false, error: describeError(error) }; + } +} + +async function fetchRequestStatus({ route, requestId }: { route: Route; requestId: string }): Promise { + let body: WrappedRequestStatus; + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${route.baseUrl}/alerts/requests/${encodeURIComponent(requestId)}`, + headers: { ...route.headers, Accept: 'application/json' }, + followRedirects: false, + timeout: POLL_TIMEOUT_MS, + }); + body = response.body; + } catch (error) { + if (error instanceof HttpError && error.response.status === 404) { + return null; + } + throw new Error(describeError(error)); + } + const status = body?.data ?? body; + if (status === undefined || status === null || (status.isSuccess === undefined && status.success === undefined)) { + return null; + } + return status; +} + +function readErrorMessage(body: unknown): string { + const parsed = typeof body === 'string' ? parseJson(body) : body; + if (typeof parsed === 'string') { + return parsed.length > 300 ? `${parsed.slice(0, 300)}…` : parsed; + } + if (parsed === null || typeof parsed !== 'object') { + return ''; + } + const parts: string[] = []; + if ('message' in parsed && typeof parsed.message === 'string') { + parts.push(parsed.message); + } + if ('errors' in parsed) { + parts.push(...describeErrorList(parsed.errors)); + } + if ('errorMessages' in parsed && Array.isArray(parsed.errorMessages)) { + parts.push(...parsed.errorMessages.filter((item): item is string => typeof item === 'string')); + } + return parts.join('; '); +} + +function describeErrorList(errors: unknown): string[] { + if (Array.isArray(errors)) { + return errors.flatMap((item) => { + if (typeof item === 'string') { + return [item]; + } + if (item !== null && typeof item === 'object' && 'title' in item && typeof item.title === 'string') { + return [item.title]; + } + return []; + }); + } + if (errors !== null && typeof errors === 'object') { + return Object.entries(errors).map(([field, message]) => `${field}: ${String(message)}`); + } + return []; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function parseJsonList(text: string): unknown { + const trimmed = text.trim(); + if (trimmed.startsWith('[')) { + return parseJson(trimmed); + } + return trimmed.split(','); +} + +function requireIdentifier(alert: string | undefined): string { + const value = alert?.trim() ?? ''; + if (value.length === 0) { + throw new Error('Enter the alert ID or alias.'); + } + return value; +} + +function nonEmpty(value: string | undefined | null): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function joinList(values: string[] | undefined): string | null { + if (!Array.isArray(values) || values.length === 0) { + return null; + } + return values.join(', '); +} + +function compact(values: Record): Record { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => (value === null ? [] : [[key, value]])), + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const KEY_HOST_ALIASES: Record = { + jsm: KEY_HOSTS.jsm, + us: KEY_HOSTS.opsgenieUs, + eu: KEY_HOSTS.opsgenieEu, +}; +const ALLOWED_SITE_SUFFIXES = ['.atlassian.net', '.jira.com']; +const CLOUD_ID_PATTERN = /^[A-Za-z0-9-]{8,100}$/; +const VALIDATION_REQUEST_ID = '00000000-0000-0000-0000-000000000000'; +const STATUS_HINTS: Record = { + 401: 'Check the connection credentials: the email and API token, or the alert API key.', + 403: 'This account or API key is not allowed to do this. Check its JSM Operations permissions.', + 404: 'Check that the alert, team or schedule exists and that the connection can see it.', + 429: 'JSM Operations is rate-limiting requests. Wait a moment and run the step again.', +}; + +type SendParams = { + route: Route; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: unknown; + timeout?: number; +}; + +type SiteParams = { + route: AccountRoute; + path: string; + queryParams?: Record; +}; + +type AlertLookup = { + route: Route; + alert: string | undefined; + identifierType: AlertIdentifierType; +}; + +type AsyncParams = { + route: Route; + method: HttpMethod; + path: string; + queryParams?: Record; + body?: unknown; + verb: string; +}; + +type AlertActionParams = { + auth: JsmAuthValue; + alert: string | undefined; + identifierType: AlertIdentifierType; + action: 'acknowledge' | 'close'; + note?: string; + user?: string; + source?: string; +}; + +type AccountAlertRequestParams = { + auth: JsmAuthValue; + feature: string; + alert: string | undefined; + identifierType: AlertIdentifierType; + method: HttpMethod; + suffix: string; + body?: unknown; + verb: string; +}; + +type ChangeTagsParams = { + auth: JsmAuthValue; + alert: string | undefined; + identifierType: AlertIdentifierType; + tags: unknown; + operation: 'add' | 'remove'; +}; + +type AddNoteParams = { + auth: JsmAuthValue; + alert: string | undefined; + identifierType: AlertIdentifierType; + note: string | undefined; + user?: string; + source?: string; +}; + +type WrappedRequestStatus = ApiRequestStatus & { data?: ApiRequestStatus }; + +type ValidationResult = { valid: true } | { valid: false; error: string }; + +export type NoteResult = RequestResult & { + note_id: string | null; + note: string | null; +}; diff --git a/packages/pieces/community/jsm-operations/src/lib/common/props.ts b/packages/pieces/community/jsm-operations/src/lib/common/props.ts new file mode 100644 index 000000000000..709735d9fbbd --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/common/props.ts @@ -0,0 +1,343 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { DropdownState, Property } from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from './client'; +import { + AccountRoute, + ApiEscalation, + ApiJiraUser, + ApiPage, + ApiSchedule, + ApiTeamList, + JsmAuthValue, + ResponderType, +} from './types'; + +export const jsmOpsProps = { + alert, + identifierType, + priority, + teams, + team, + schedule, + schedules, + escalation, + escalations, + user, + users, + responderType, + responder, +}; + +export const jsmOpsOptions = { + listTeams, + listSchedules, + listEscalations, + listUsers, + listResponders, +}; + +export const PRIORITY_OPTIONS = [ + { label: 'P1 - Critical', value: 'P1' }, + { label: 'P2 - High', value: 'P2' }, + { label: 'P3 - Moderate', value: 'P3' }, + { label: 'P4 - Low', value: 'P4' }, + { label: 'P5 - Informational', value: 'P5' }, +]; + +function alert() { + return Property.ShortText({ + displayName: 'Alert ID or Alias', + description: 'The alert ID, or its alias if you pick Alias below.', + placeholder: 'e.g. 70413a06-38d6-4c85-92b8-5ebc900d42e2', + required: true, + }); +} + +function identifierType() { + return Property.StaticDropdown({ + displayName: 'Identify Alert By', + description: 'Whether the value above is the alert ID or its alias.', + required: true, + defaultValue: 'id', + options: { + options: [ + { label: 'Alert ID', value: 'id' }, + { label: 'Alias', value: 'alias' }, + ], + }, + }); +} + +function priority({ required, defaultValue }: { required: boolean; defaultValue?: string }) { + return Property.StaticDropdown({ + displayName: 'Priority', + description: 'P1 is the most urgent, P5 the least.', + required, + defaultValue, + options: { options: PRIORITY_OPTIONS }, + }); +} + +function teams({ displayName, description }: LabelParams) { + return Property.MultiSelectDropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: false, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listTeams({ route }) }), + }); +} + +function team({ displayName, description, required }: LabelParams & { required: boolean }) { + return Property.Dropdown({ + auth: jsmOpsAuth, + displayName, + description, + required, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listTeams({ route }) }), + }); +} + +function schedule({ displayName, description }: LabelParams) { + return Property.Dropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: true, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listSchedules({ route }) }), + }); +} + +function schedules({ displayName, description }: LabelParams) { + return Property.MultiSelectDropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: false, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listSchedules({ route }) }), + }); +} + +function escalation({ displayName, description }: LabelParams) { + return Property.Dropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: true, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listEscalations({ route }) }), + }); +} + +function escalations({ displayName, description }: LabelParams) { + return Property.MultiSelectDropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: false, + refreshers: [], + options: async ({ auth }) => withAccount({ auth, load: (route) => listEscalations({ route }) }), + }); +} + +function user({ displayName, description }: LabelParams) { + return Property.Dropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: true, + refreshers: [], + refreshOnSearch: true, + options: async ({ auth }, { searchValue }) => + withAccount({ auth, load: (route) => listUsers({ route, search: searchValue }) }), + }); +} + +function users({ displayName, description }: LabelParams) { + return Property.MultiSelectDropdown({ + auth: jsmOpsAuth, + displayName, + description, + required: false, + refreshers: [], + refreshOnSearch: true, + options: async ({ auth }, { searchValue }) => + withAccount({ auth, load: (route) => listUsers({ route, search: searchValue }) }), + }); +} + +function responderType() { + return Property.StaticDropdown({ + displayName: 'Responder Type', + description: 'What kind of responder to add.', + required: true, + defaultValue: 'team', + options: { + options: [ + { label: 'Team', value: 'team' }, + { label: 'User', value: 'user' }, + { label: 'Schedule', value: 'schedule' }, + { label: 'Escalation', value: 'escalation' }, + ], + }, + }); +} + +function responder() { + return Property.Dropdown({ + auth: jsmOpsAuth, + displayName: 'Responder', + description: 'The team, user, schedule or escalation to add.', + required: true, + refreshers: ['responderType'], + refreshOnSearch: true, + options: async ({ auth, responderType }, { searchValue }) => { + if (!isResponderType(responderType)) { + return { disabled: true, options: [], placeholder: 'Pick a responder type first.' }; + } + return withAccount({ auth, load: (route) => listResponders({ route, type: responderType, search: searchValue }) }); + }, + }); +} + +async function listTeams({ route }: { route: AccountRoute }): Promise { + const body = await jsmOps.send({ route, method: HttpMethod.GET, path: '/teams' }); + const teams = Array.isArray(body) ? body : body.platformTeams ?? []; + return teams.flatMap((item) => + typeof item.teamId === 'string' ? [{ label: item.teamName ?? item.teamId, value: item.teamId }] : [], + ); +} + +async function listSchedules({ route }: { route: AccountRoute }): Promise { + const found: ApiSchedule[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const body = await jsmOps.send>({ + route, + method: HttpMethod.GET, + path: '/schedules', + queryParams: { size: String(SCHEDULE_PAGE_SIZE), offset: String(page * SCHEDULE_PAGE_SIZE) }, + }); + const values = body.values ?? []; + found.push(...values); + if (values.length < SCHEDULE_PAGE_SIZE) { + break; + } + } + return found.flatMap((item) => + typeof item.id === 'string' + ? [{ label: item.timezone ? `${item.name ?? item.id} (${item.timezone})` : item.name ?? item.id, value: item.id }] + : [], + ); +} + +async function listEscalations({ route }: { route: AccountRoute }): Promise { + const teamOptions = await listTeams({ route }); + const found: Option[] = []; + for (let start = 0; start < teamOptions.length; start += ESCALATION_TEAM_BATCH) { + const batch = teamOptions.slice(start, start + ESCALATION_TEAM_BATCH); + const perTeam = await Promise.all(batch.map((teamOption) => listTeamEscalations({ route, team: teamOption }))); + found.push(...perTeam.flat()); + } + return found; +} + +async function listTeamEscalations({ route, team }: { route: AccountRoute; team: Option }): Promise { + const found: Option[] = []; + for (let page = 0; page < MAX_PAGES; page++) { + const body = await jsmOps.send>({ + route, + method: HttpMethod.GET, + path: `/teams/${encodeURIComponent(team.value)}/escalations`, + queryParams: { size: String(ESCALATION_PAGE_SIZE), offset: String(page * ESCALATION_PAGE_SIZE) }, + }); + const values = body.values ?? []; + for (const item of values) { + if (typeof item.id === 'string') { + found.push({ label: `${item.name ?? item.id} (${team.label})`, value: item.id }); + } + } + if (values.length < ESCALATION_PAGE_SIZE) { + break; + } + } + return found; +} + +async function listUsers({ route, search }: { route: AccountRoute; search?: string }): Promise { + const query = search?.trim() ?? ''; + const found = + query.length > 0 + ? await jsmOps.sendToSite({ + route, + path: '/rest/api/3/user/search', + queryParams: { query, maxResults: '50' }, + }) + : await jsmOps.sendToSite({ + route, + path: '/rest/api/3/users/search', + queryParams: { maxResults: '100' }, + }); + return (Array.isArray(found) ? found : []).flatMap((item) => { + if (typeof item.accountId !== 'string' || item.accountType !== 'atlassian' || item.active === false) { + return []; + } + const name = item.displayName ?? item.accountId; + return [{ label: item.emailAddress ? `${name} (${item.emailAddress})` : name, value: item.accountId }]; + }); +} + +async function listResponders({ route, type, search }: { route: AccountRoute; type: ResponderType; search?: string }): Promise { + if (type === 'team') { + return listTeams({ route }); + } + if (type === 'schedule') { + return listSchedules({ route }); + } + if (type === 'escalation') { + return listEscalations({ route }); + } + return listUsers({ route, search }); +} + +async function withAccount({ auth, load }: WithAccountParams): Promise> { + if (auth === undefined) { + return { disabled: true, options: [], placeholder: 'Connect your account first.' }; + } + if (jsmOps.isKeyConnection(auth)) { + return { disabled: true, options: [], placeholder: 'Needs an Atlassian Account connection, not an API key.' }; + } + try { + const route = await jsmOps.requireAccountRoute({ auth, feature: 'This list' }); + const options = await load(route); + return { + disabled: false, + options, + placeholder: options.length === 0 ? 'Nothing found on this site.' : undefined, + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load the list: ${jsmOps.describeError(error)}` }; + } +} + +function isResponderType(value: unknown): value is ResponderType { + return value === 'team' || value === 'user' || value === 'schedule' || value === 'escalation'; +} + +const MAX_PAGES = 5; +const SCHEDULE_PAGE_SIZE = 50; +const ESCALATION_PAGE_SIZE = 100; +const ESCALATION_TEAM_BATCH = 10; + +type Option = { label: string; value: string }; + +type LabelParams = { displayName: string; description: string }; + +type WithAccountParams = { + auth: JsmAuthValue | undefined; + load: (route: AccountRoute) => Promise; +}; diff --git a/packages/pieces/community/jsm-operations/src/lib/common/types.ts b/packages/pieces/community/jsm-operations/src/lib/common/types.ts new file mode 100644 index 000000000000..dfe96bbba14f --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/common/types.ts @@ -0,0 +1,208 @@ +import { AppConnectionType } from '@activepieces/pieces-framework'; + +export type AccountAuthProps = { + siteUrl: string; + email: string; + apiToken: string; + cloudId?: string; +}; + +export type KeyAuthProps = { + host: string; + apiKey: string; +}; + +export type AccountConnection = { + type: AppConnectionType.CUSTOM_AUTH; + props: AccountAuthProps; +}; + +export type KeyConnection = { + type: AppConnectionType.BASIC_AUTH; + username: string; + password: string; +}; + +export type JsmAuthValue = AccountConnection | KeyConnection; + +export type AccountRoute = { + kind: 'account'; + baseUrl: string; + siteUrl: string; + headers: Record; +}; + +export type KeyRoute = { + kind: 'key'; + baseUrl: string; + headers: Record; +}; + +export type Route = AccountRoute | KeyRoute; + +export type AlertIdentifierType = 'id' | 'alias'; + +export type ResponderType = 'team' | 'user' | 'escalation' | 'schedule'; + +export type ApiResponder = { + id?: string; + name?: string; + username?: string; + type: string; +}; + +export type ApiAlert = { + id?: string; + tinyId?: string; + createdAt?: string; + updatedAt?: string; + message?: string; + entity?: string; + source?: string; + status?: string; + alias?: string; + tags?: string[]; + extraProperties?: Record; + description?: string; + acknowledged?: boolean; + ackTime?: string; + closeTime?: string; + count?: number; + owner?: string; + snoozed?: boolean; + snoozedUntil?: string; + lastOccuredAt?: string; + lastOccurredAt?: string; + integrationType?: string; + integrationName?: string; + priority?: string; + responders?: ApiResponder[]; + actions?: string[]; + seen?: boolean; +}; + +export type ApiAlertList = { + values?: ApiAlert[]; + count?: number; +}; + +export type ApiAsyncResponse = { + result?: string; + requestId?: string; + took?: number; +}; + +export type ApiRequestStatus = { + success?: boolean; + isSuccess?: boolean; + action?: string; + processedAt?: string; + integrationId?: string; + status?: string; + alertId?: string; + alias?: string; +}; + +export type ApiNote = { + id?: string; + createdAt?: string; + updatedAt?: string; + note?: string; + owner?: string; +}; + +export type ApiTeam = { teamId?: string; teamName?: string }; + +export type ApiTeamList = ApiTeam[] | { platformTeams?: ApiTeam[] }; + +export type ApiSchedule = { + id?: string; + name?: string; + timezone?: string; + enabled?: boolean; +}; + +export type ApiEscalation = { + id?: string; + name?: string; +}; + +export type ApiPage = { + values?: T[]; + links?: { next?: string }; +}; + +export type ApiJiraUser = { + accountId?: string; + accountType?: string; + displayName?: string; + emailAddress?: string; + active?: boolean; +}; + +export type ApiOnCallParticipant = { + id?: string; + type?: string; + name?: string; + forwardedFrom?: { id?: string; type?: string }; + onCallParticipants?: ApiOnCallParticipant[]; +}; + +export type ApiOnCallResponse = { + onCallParticipants?: ApiOnCallParticipant[]; + onCallUsers?: string[]; +}; + +export type AlertOutput = { + id: string | null; + tiny_id: string | null; + message: string | null; + description: string | null; + status: string | null; + priority: string | null; + alias: string | null; + entity: string | null; + source: string | null; + owner: string | null; + acknowledged: boolean | null; + seen: boolean | null; + snoozed: boolean | null; + snoozed_until: string | null; + count: number | null; + tags: string | null; + actions: string | null; + responders: string | null; + extra_properties: Record; + integration_name: string | null; + integration_type: string | null; + created_at: string | null; + updated_at: string | null; + last_occurred_at: string | null; + ack_time: string | null; + close_time: string | null; +}; + +export type RequestResult = { + request_id: string | null; + processed: boolean; + success: boolean | null; + action: string | null; + status: string | null; + alert_id: string | null; + alias: string | null; + processed_at: string | null; +}; + +export type AlertActionResult = RequestResult & { + note_added: boolean | null; + note_error: string | null; +}; + +export type OnCallParticipantOutput = { + id: string | null; + type: string | null; + name: string | null; + parent_id: string | null; + parent_type: string | null; + forwarded_from_id: string | null; +}; diff --git a/packages/pieces/community/jsm-operations/src/lib/output-schemas.ts b/packages/pieces/community/jsm-operations/src/lib/output-schemas.ts new file mode 100644 index 000000000000..2f143a930772 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/output-schemas.ts @@ -0,0 +1,120 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const alertOutputSchema: OutputSchema = { + fields: alertFields(), +}; + +export const findAlertsOutputSchema: OutputSchema = { + itemLabel: '{message}', + fields: [ + { + key: 'alerts', + label: 'Alerts', + value: '', + labelKey: 'message', + description: 'One entry per matching alert, in the chosen sort order.', + listItems: alertFields(), + }, + ], +}; + +export const requestResultOutputSchema: OutputSchema = { + fields: requestResultFields(), +}; + +export const alertActionOutputSchema: OutputSchema = { + fields: [ + ...requestResultFields(), + { key: 'note_added', label: 'Note Added', format: 'boolean', description: 'Empty when no note was given.' }, + { key: 'note_error', label: 'Note Error', description: 'Why the note could not be added. The alert itself was still updated.' }, + ], +}; + +export const addNoteOutputSchema: OutputSchema = { + fields: [ + ...requestResultFields(), + { key: 'note_id', label: 'Note ID', description: 'Empty on API key connections, which process notes asynchronously.' }, + { key: 'note', label: 'Note' }, + ], +}; + +export const updateAlertOutputSchema: OutputSchema = { + fields: [ + { key: 'alert_id', label: 'Alert ID' }, + { key: 'updated_fields', label: 'Updated Fields', description: 'Comma-separated list: message, description, priority.' }, + { key: 'processed', label: 'All Updates Processed', format: 'boolean' }, + { + key: 'requests', + label: 'Update Requests', + labelKey: 'field', + listItems: [{ key: 'field', label: 'Field' }, ...requestResultFields()], + }, + ], +}; + +export const onCallOutputSchema: OutputSchema = { + fields: [ + { key: 'schedule_id', label: 'Schedule ID' }, + { key: 'date', label: 'Checked At', format: 'datetime', description: 'Empty means the current time.' }, + { key: 'on_call_user_ids', label: 'On-Call User Account IDs', description: 'Comma-separated Atlassian account IDs.' }, + { key: 'on_call_user_count', label: 'On-Call User Count', format: 'number' }, + { + key: 'participants', + label: 'On-Call Participants', + labelKey: 'id', + description: 'Everyone on call, including users reached through a team or escalation.', + listItems: [ + { key: 'id', label: 'ID' }, + { key: 'type', label: 'Type', description: 'user, team, escalation or schedule.' }, + { key: 'name', label: 'Name' }, + { key: 'parent_id', label: 'Reached Through ID', description: 'The team or escalation this user is on call for.' }, + { key: 'parent_type', label: 'Reached Through Type' }, + { key: 'forwarded_from_id', label: 'Forwarded From User ID' }, + ], + }, + ], +}; + +function alertFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Alert ID' }, + { key: 'tiny_id', label: 'Tiny ID' }, + { key: 'message', label: 'Message' }, + { key: 'description', label: 'Description' }, + { key: 'status', label: 'Status', description: 'open, acked, snoozed, resolved or closed.' }, + { key: 'priority', label: 'Priority' }, + { key: 'alias', label: 'Alias' }, + { key: 'entity', label: 'Entity' }, + { key: 'source', label: 'Source' }, + { key: 'owner', label: 'Owner' }, + { key: 'acknowledged', label: 'Acknowledged', format: 'boolean' }, + { key: 'seen', label: 'Seen', format: 'boolean' }, + { key: 'snoozed', label: 'Snoozed', format: 'boolean' }, + { key: 'snoozed_until', label: 'Snoozed Until', format: 'datetime' }, + { key: 'count', label: 'Occurrence Count', format: 'number' }, + { key: 'tags', label: 'Tags', description: 'Comma-separated.' }, + { key: 'actions', label: 'Custom Actions', description: 'Comma-separated.' }, + { key: 'responders', label: 'Responders', description: 'Comma-separated type:id pairs.' }, + { key: 'extra_properties', label: 'Extra Properties', dynamicKey: true }, + { key: 'integration_name', label: 'Integration Name' }, + { key: 'integration_type', label: 'Integration Type' }, + { key: 'created_at', label: 'Created At', format: 'datetime' }, + { key: 'updated_at', label: 'Updated At', format: 'datetime' }, + { key: 'last_occurred_at', label: 'Last Occurred At', format: 'datetime' }, + { key: 'ack_time', label: 'Acknowledged At', format: 'datetime' }, + { key: 'close_time', label: 'Closed At', format: 'datetime' }, + ]; +} + +function requestResultFields(): OutputSchema['fields'] { + return [ + { key: 'alert_id', label: 'Alert ID' }, + { key: 'alias', label: 'Alias' }, + { key: 'processed', label: 'Processed', format: 'boolean', description: 'False when the request was still queued after about 10 seconds.' }, + { key: 'success', label: 'Succeeded', format: 'boolean' }, + { key: 'status', label: 'Status Message' }, + { key: 'action', label: 'Action' }, + { key: 'request_id', label: 'Request ID' }, + { key: 'processed_at', label: 'Processed At', format: 'datetime' }, + ]; +} diff --git a/packages/pieces/community/jsm-operations/src/lib/triggers/new-alert.ts b/packages/pieces/community/jsm-operations/src/lib/triggers/new-alert.ts new file mode 100644 index 000000000000..bc3fff655c00 --- /dev/null +++ b/packages/pieces/community/jsm-operations/src/lib/triggers/new-alert.ts @@ -0,0 +1,145 @@ +import { DedupeStrategy, HttpMethod, Polling, pollingHelper } from '@activepieces/pieces-common'; +import { + AppConnectionValueForAuthProperty, + createTrigger, + Property, + StaticPropsValue, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { jsmOpsAuth } from '../auth'; +import { jsmOps } from '../common/client'; +import { AlertOutput, ApiAlert, ApiAlertList } from '../common/types'; +import { alertOutputSchema } from '../output-schemas'; + +export const newAlertTrigger = createTrigger({ + auth: jsmOpsAuth, + name: 'new_alert', + classification: 'READ', + displayName: 'New Alert', + description: 'Triggers when a new alert is created.', + aiMetadata: { + description: + 'Fires once per new JSM Operations alert, checked every few minutes, optionally limited by an alert search query. Each run carries one alert. Needs an Atlassian account connection.', + }, + props: newAlertProps(), + outputSchema: alertOutputSchema, + sampleData: sampleAlert(), + type: TriggerStrategy.POLLING, + async test(context) { + return pollingHelper.test(polling(), context); + }, + async onEnable(context) { + await jsmOps.requireAccountRoute({ auth: context.auth, feature: 'The New Alert trigger' }); + await pollingHelper.onEnable(polling(), context); + }, + async onDisable(context) { + await pollingHelper.onDisable(polling(), context); + }, + async run(context) { + return pollingHelper.poll(polling(), context); + }, +}); + +function newAlertProps() { + return { + query: Property.ShortText({ + displayName: 'Only Alerts Matching', + description: 'Optional alert search query to filter new alerts.', + placeholder: 'e.g. priority: P1 AND tag: production', + required: false, + }), + }; +} + +function polling(): Polling, StaticPropsValue> { + return { + strategy: DedupeStrategy.TIMEBASED, + items: async ({ auth, propsValue, lastFetchEpochMS }) => { + const alerts = await fetchNewAlerts({ auth, filter: propsValue.query, since: lastFetchEpochMS }); + return alerts.map((alert) => ({ + epochMilliSeconds: new Date(alert.createdAt ?? 0).getTime(), + data: jsmOps.toAlertOutput(alert), + })); + }, + }; +} + +async function fetchNewAlerts({ auth, filter, since }: FetchParams): Promise { + const route = await jsmOps.requireAccountRoute({ auth, feature: 'The New Alert trigger' }); + const query = buildPollQuery({ filter, since }); + const pages = since > 0 ? MAX_PAGES : 1; + const size = since > 0 ? PAGE_SIZE : TEST_PAGE_SIZE; + const found: ApiAlert[] = []; + for (let page = 0; page < pages; page++) { + const body = await jsmOps.send({ + route, + method: HttpMethod.GET, + path: '/alerts', + queryParams: { + ...(query.length > 0 ? { query } : {}), + sort: 'createdAt', + order: since > 0 ? 'asc' : 'desc', + size: String(size), + offset: String(page * size), + }, + }); + const values = body.values ?? []; + found.push(...values); + if (values.length < size) { + break; + } + } + return found; +} + +function buildPollQuery({ filter, since }: { filter: string | undefined; since: number }): string { + const trimmed = filter?.trim() ?? ''; + const parts = [ + ...(since > 0 ? [`createdAt > ${Math.floor(since)}`] : []), + ...(trimmed.length > 0 ? [`(${trimmed})`] : []), + ]; + return parts.join(' AND '); +} + +function sampleAlert(): AlertOutput { + return { + id: '70413a06-38d6-4c85-92b8-5ebc900d42e2', + tiny_id: '1791', + message: 'CPU above 90% on web-01', + description: 'CPU usage has been above 90% for 10 minutes.', + status: 'open', + priority: 'P2', + alias: 'web-01-high-cpu', + entity: 'web-01', + source: 'Monitoring', + owner: null, + acknowledged: false, + seen: false, + snoozed: false, + snoozed_until: null, + count: 1, + tags: 'production, cpu', + actions: null, + responders: 'team:4513b7ea-3b91-438f-b7e4-e3e54af9147c', + extra_properties: { region: 'eu-west-1' }, + integration_name: 'API', + integration_type: 'API', + created_at: '2026-09-28T08:15:00.000Z', + updated_at: '2026-09-28T08:15:00.000Z', + last_occurred_at: '2026-09-28T08:15:00.000Z', + ack_time: null, + close_time: null, + }; +} + +const MAX_PAGES = 5; +const PAGE_SIZE = 100; +const TEST_PAGE_SIZE = 10; + +type NewAlertProps = ReturnType; + +type FetchParams = { + auth: AppConnectionValueForAuthProperty; + filter: string | undefined; + since: number; +}; diff --git a/packages/pieces/community/jsm-operations/test/jsm-operations.test.ts b/packages/pieces/community/jsm-operations/test/jsm-operations.test.ts new file mode 100644 index 000000000000..4130eb61187e --- /dev/null +++ b/packages/pieces/community/jsm-operations/test/jsm-operations.test.ts @@ -0,0 +1,1171 @@ +import { HttpError, httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { + AppConnectionType, + AppConnectionValueForAuthProperty, + createMockActionContext, + createMockPollingTriggerContext, + InputPropertyMap, + OutputSchema, + PropertyContext, + StaticPropsValue, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { acknowledgeAlertAction } from '../src/lib/actions/acknowledge-alert'; +import { addNoteAction } from '../src/lib/actions/add-note'; +import { addTagsAction } from '../src/lib/actions/add-tags'; +import { assignAlertAction } from '../src/lib/actions/assign-alert'; +import { closeAlertAction } from '../src/lib/actions/close-alert'; +import { createAlertAction } from '../src/lib/actions/create-alert'; +import { findAlertsAction } from '../src/lib/actions/find-alerts'; +import { getAlertAction } from '../src/lib/actions/get-alert'; +import { getOnCallAction } from '../src/lib/actions/get-on-call'; +import { removeTagsAction } from '../src/lib/actions/remove-tags'; +import { updateAlertAction } from '../src/lib/actions/update-alert'; +import { jsmAccountAuth, jsmKeyAuth, jsmOpsAuth } from '../src/lib/auth'; +import { jsmOps, KEY_HOSTS } from '../src/lib/common/client'; +import { jsmOpsProps } from '../src/lib/common/props'; +import { jsmOperations } from '../src'; +import { newAlertTrigger } from '../src/lib/triggers/new-alert'; + +const sendRequest = vi.fn(); + +const CLOUD_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const FAKE_ALERT_KEY = 'fake-alert-key'; +const ACCOUNT_BASE = `https://api.atlassian.com/jsm/ops/api/${CLOUD_ID}/v1`; +const SITE = 'https://acme.atlassian.net'; + +const ACCOUNT_AUTH: JsmConnection = { + type: AppConnectionType.CUSTOM_AUTH, + props: { siteUrl: 'acme.atlassian.net', email: 'ops@acme.com', apiToken: 'tok', cloudId: CLOUD_ID }, +}; + +const ACCOUNT_AUTH_NO_CLOUD_ID: JsmConnection = { + type: AppConnectionType.CUSTOM_AUTH, + props: { siteUrl: 'https://acme.atlassian.net/', email: 'ops@acme.com', apiToken: 'tok', cloudId: undefined }, +}; + +function keyAuth(host: string): JsmConnection { + return { type: AppConnectionType.BASIC_AUTH, username: host, password: FAKE_ALERT_KEY }; +} + +const API_ALERT = { + id: 'alert-1', + tinyId: '42', + createdAt: '2026-09-28T08:15:00.000Z', + updatedAt: '2026-09-28T08:16:00.000Z', + message: 'CPU high', + entity: 'web-01', + source: 'Monitor', + status: 'open', + alias: 'web-01-cpu', + tags: ['prod', 'cpu'], + extraProperties: { region: 'eu' }, + description: 'CPU above 90%', + acknowledged: false, + count: 3, + owner: '', + snoozed: false, + lastOccuredAt: '2026-09-28T08:15:30.000Z', + integrationType: 'API', + integrationName: 'Default API', + priority: 'P2', + responders: [{ id: 'team-1', type: 'team' }], + actions: ['Restart'], + seen: true, +}; + +function mockApi(handler: Handler) { + sendRequest.mockImplementation(async (request: Request) => { + if (request.url === `${SITE}/_edge/tenant_info`) { + return { status: 200, body: { cloudId: CLOUD_ID } }; + } + const reply = handler(request); + if (reply === undefined) { + throw new Error(`Unexpected request ${request.method} ${request.url}`); + } + if (reply.status >= 400) { + throw new HttpError(request.body, { status: reply.status, responseBody: reply.body }); + } + return { status: reply.status, body: reply.body }; + }); +} + +function requests(): Request[] { + return sendRequest.mock.calls.map((call) => call[0]); +} + +function findRequest({ method, url }: { method: string; url: string }): Request { + const found = requests().find((request) => request.method === method && request.url === url); + if (found === undefined) { + throw new Error(`no ${method} ${url} request`); + } + return found; +} + +function accepted(requestId: string): Reply { + return { status: 202, body: { result: 'Request will be processed', requestId, took: 0.01 } }; +} + +function processed({ requestId, alertId, success, status }: ProcessedParams): Reply { + return { + status: 200, + body: { + action: 'Create', + processedAt: '2026-09-28T08:15:01.000Z', + isSuccess: success, + status, + alertId, + alias: 'web-01-cpu', + requestId, + }, + }; +} + +function makeContext({ propsValue, auth }: { propsValue: StaticPropsValue; auth: JsmConnection }) { + return { ...createMockActionContext({ propsValue }), auth }; +} + +function makeTriggerContext({ propsValue, auth, store }: TriggerContextParams) { + const { files } = createMockActionContext({ propsValue }); + return { ...createMockPollingTriggerContext({ propsValue }), auth, store, files }; +} + +function memoryStore(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + put: async (key: string, value: T): Promise => { + data.set(key, value); + return value; + }, + get: async (key: string): Promise => { + const value = data.get(key); + return value === undefined ? null : readAs(value); + }, + delete: async (key: string): Promise => { + data.delete(key); + }, + }; +} + +function readAs(value: unknown): T { + return JSON.parse(JSON.stringify(value)); +} + +function propertyContext(searchValue?: string): PropertyContext { + const { server, project, flows, connections } = createMockActionContext({ propsValue: {} }); + return { server, project, flows, connections, searchValue }; +} + +async function settle(promise: Promise): Promise { + const guarded: Promise> = promise.then( + (value): Settled => ({ ok: true, value }), + (error: unknown): Settled => ({ ok: false, error }), + ); + await vi.runAllTimersAsync(); + const result = await guarded; + if (!result.ok) { + throw result.error; + } + return result.value; +} + +async function settleError(promise: Promise): Promise { + try { + await settle(promise); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error('expected the promise to reject'); +} + +function fieldKeys(fields: OutputSchema['fields'] | undefined): string[] { + return (fields ?? []).map((field) => field.key).sort(); +} + +function objectKeys(value: unknown): string[] { + if (value === null || typeof value !== 'object') { + throw new Error('expected an object'); + } + return Object.keys(value).sort(); +} + +function firstItem(value: unknown): unknown { + if (!Array.isArray(value) || value.length === 0) { + throw new Error('expected a non-empty list'); + } + return value[0]; +} + +function validate({ auth, props }: { auth: typeof jsmAccountAuth | typeof jsmKeyAuth; props: Record }) { + const server = { apiUrl: 'http://localhost:3000', publicUrl: 'http://localhost:4200', mintOidcToken: async () => 'oidc' }; + if (auth === jsmKeyAuth) { + return jsmKeyAuth.validate?.({ auth: { username: props['host'] ?? '', password: props['apiKey'] ?? '' }, server }); + } + return jsmAccountAuth.validate?.({ + auth: { + siteUrl: props['siteUrl'] ?? '', + email: props['email'] ?? '', + apiToken: props['apiToken'] ?? '', + cloudId: props['cloudId'], + }, + server, + }); +} + +function pollingTrigger() { + const trigger = newAlertTrigger; + if (trigger.type !== TriggerStrategy.POLLING) { + throw new Error('expected a polling trigger'); + } + return trigger; +} + +function participantAt({ result, index }: { result: unknown; index: number }): unknown { + if (result === null || typeof result !== 'object' || !('participants' in result) || !Array.isArray(result.participants)) { + throw new Error('expected participants'); + } + return result.participants[index]; +} + +beforeEach(() => { + sendRequest.mockReset(); + vi.spyOn(httpClient, 'sendRequest').mockImplementation((request) => sendRequest(request)); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('piece definition', () => { + it('loads with one auth per connection type and every action and trigger', () => { + expect(jsmOperations.displayName).toBe('Jira Service Management Operations'); + expect(jsmOperations.description.startsWith('Opsgenie')).toBe(true); + expect(Object.keys(jsmOperations.actions())).toHaveLength(16); + expect(Object.keys(jsmOperations.triggers())).toEqual(['new_alert']); + expect(Object.values(jsmOperations.actions()).filter((action) => action.name !== 'custom_api_call').every((action) => action.outputSchema !== undefined && action.audience === 'both')).toBe(true); + }); +}); + +describe('list inputs', () => { + it('accepts arrays, JSON strings and comma-separated text', () => { + expect(jsmOps.parseStringList(['a', ' b ', '', 3])).toEqual(['a', 'b', '3']); + expect(jsmOps.parseStringList('["prod","cpu"]')).toEqual(['prod', 'cpu']); + expect(jsmOps.parseStringList('prod, cpu')).toEqual(['prod', 'cpu']); + expect(jsmOps.parseStringList(undefined)).toEqual([]); + }); +}); + +describe('connection routing', () => { + it('tells the two connection types apart by connection type', () => { + expect(jsmOps.isKeyConnection(keyAuth(KEY_HOSTS.opsgenieEu))).toBe(true); + expect(jsmOps.isKeyConnection(keyAuth('eu'))).toBe(true); + expect(jsmOps.isKeyConnection(ACCOUNT_AUTH)).toBe(false); + expect(jsmOpsAuth).toEqual([jsmAccountAuth, jsmKeyAuth]); + }); + + it('uses basic auth against the cloud-scoped JSM Operations API for account connections', async () => { + const route = await jsmOps.routeFor(ACCOUNT_AUTH); + const expected = `Basic ${Buffer.from('ops@acme.com:tok').toString('base64')}`; + expect(route).toEqual({ kind: 'account', baseUrl: ACCOUNT_BASE, siteUrl: SITE, headers: { Authorization: expected } }); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('looks the Cloud ID up from the site when it is empty, without following redirects', async () => { + mockApi(() => undefined); + + const route = await jsmOps.routeFor(ACCOUNT_AUTH_NO_CLOUD_ID); + + expect(route.baseUrl).toBe(ACCOUNT_BASE); + const lookup = findRequest({ method: 'GET', url: `${SITE}/_edge/tenant_info` }); + expect(lookup.followRedirects).toBe(false); + expect(lookup.timeout).toBeGreaterThan(0); + }); + + it('rejects site URLs that are not https Atlassian Cloud hosts', () => { + expect(jsmOps.normalizeSiteUrl('https://acme.atlassian.net/jira/')).toBe(SITE); + expect(() => jsmOps.normalizeSiteUrl('http://acme.atlassian.net')).toThrow(/not an Atlassian Cloud site/); + expect(() => jsmOps.normalizeSiteUrl('https://evil.example.com')).toThrow(/not an Atlassian Cloud site/); + expect(() => jsmOps.normalizeSiteUrl('https://atlassian.net.evil.com')).toThrow(/not an Atlassian Cloud site/); + expect(() => jsmOps.normalizeSiteUrl('https://acme.atlassian.net:8443')).toThrow(/not an Atlassian Cloud site/); + }); + + it('fails clearly when the site returns no Cloud ID', async () => { + sendRequest.mockResolvedValue({ status: 200, body: { cloudId: '' } }); + + await expect(jsmOps.routeFor(ACCOUNT_AUTH_NO_CLOUD_ID)).rejects.toThrow(/did not return a Cloud ID/); + }); + + it.each([ + [KEY_HOSTS.jsm, 'https://api.atlassian.com/jsm/ops/integration/v2'], + [KEY_HOSTS.opsgenieUs, 'https://api.opsgenie.com/v2'], + [KEY_HOSTS.opsgenieEu, 'https://api.eu.opsgenie.com/v2'], + ])('sends GenieKey auth to the %s alert API', async (host, baseUrl) => { + const route = await jsmOps.routeFor(keyAuth(host)); + expect(route).toEqual({ kind: 'key', baseUrl, headers: { Authorization: `GenieKey ${FAKE_ALERT_KEY}` } }); + }); + + it.each([ + ['jsm', KEY_HOSTS.jsm], + [' US ', KEY_HOSTS.opsgenieUs], + ['eu', KEY_HOSTS.opsgenieEu], + ['api.eu.opsgenie.com/', KEY_HOSTS.opsgenieEu], + ['https://api.atlassian.com/jsm/ops/integration', KEY_HOSTS.jsm], + ])('accepts %s as the API host', (value, host) => { + expect(jsmOps.resolveKeyHost(value)).toBe(host); + }); + + it('refuses API hosts outside the allowed list', async () => { + await expect(jsmOps.routeFor(keyAuth('https://attacker.example.com'))).rejects.toThrow(/Unknown API host/); + await expect(jsmOps.routeFor(keyAuth('http://api.opsgenie.com'))).rejects.toThrow(/Unknown API host/); + }); +}); + +describe('connection validation', () => { + it('accepts an account that can list alerts', async () => { + mockApi((request) => (request.url === `${ACCOUNT_BASE}/alerts` ? { status: 200, body: { values: [] } } : undefined)); + + await expect( + validate({ auth: jsmAccountAuth, props: { siteUrl: SITE, email: 'ops@acme.com', apiToken: 'tok', cloudId: CLOUD_ID } }), + ).resolves.toEqual({ valid: true }); + expect(findRequest({ method: 'GET', url: `${ACCOUNT_BASE}/alerts` }).queryParams).toEqual({ size: '1' }); + }); + + it('reports the vendor message when the account is rejected', async () => { + mockApi(() => ({ status: 401, body: { code: 401, message: 'Unauthorized' } })); + + const result = await validate({ auth: jsmAccountAuth, props: { siteUrl: SITE, email: 'ops@acme.com', apiToken: 'bad', cloudId: CLOUD_ID } }); + + expect(result).toEqual({ valid: false, error: expect.stringContaining('HTTP 401): Unauthorized') }); + }); + + it('accepts an API key when an unknown request ID gives 404 and rejects it on 401', async () => { + mockApi(() => ({ status: 404, body: { message: 'Request not found' } })); + await expect(validate({ auth: jsmKeyAuth, props: { host: KEY_HOSTS.jsm, apiKey: 'good' } })).resolves.toEqual({ valid: true }); + + mockApi(() => ({ status: 401, body: { message: 'Could not authenticate' } })); + const result = await validate({ auth: jsmKeyAuth, props: { host: KEY_HOSTS.opsgenieUs, apiKey: 'bad' } }); + expect(result).toEqual({ valid: false, error: expect.stringContaining('Could not authenticate') }); + }); +}); + +describe('Create Alert', () => { + const defaults = { + description: undefined, + priority: 'P3', + alias: undefined, + responderTeams: undefined, + responderUsers: undefined, + responderSchedules: undefined, + responderEscalations: undefined, + responderNames: undefined, + tags: undefined, + entity: undefined, + source: undefined, + note: undefined, + extraProperties: undefined, + actions: undefined, + user: undefined, + }; + + function runCreate({ auth, propsValue }: { auth: JsmConnection; propsValue: Partial> }) { + return createAlertAction.run( + makeContext({ auth, propsValue: { message: 'CPU high', ...defaults, ...propsValue } }), + ); + } + + it('posts the account-route body and returns the processed alert ID', async () => { + mockApi((request) => { + if (request.method === 'POST' && request.url === `${ACCOUNT_BASE}/alerts`) { + return accepted('req-1'); + } + if (request.url === `${ACCOUNT_BASE}/alerts/requests/req-1`) { + return processed({ requestId: 'req-1', alertId: 'alert-1', success: true, status: 'Created alert' }); + } + return undefined; + }); + + const result = await settle( + runCreate({ + auth: ACCOUNT_AUTH, + propsValue: { + alias: ' web-01-cpu ', + priority: 'P1', + responderTeams: ['team-1'], + responderUsers: ['acc-1'], + tags: ['prod', ' ', 'cpu'], + extraProperties: { region: 'eu', attempts: 3 }, + user: 'ignored-on-account-route', + }, + }), + ); + + expect(findRequest({ method: 'POST', url: `${ACCOUNT_BASE}/alerts` }).body).toEqual({ + message: 'CPU high', + alias: 'web-01-cpu', + priority: 'P1', + responders: [ + { id: 'team-1', type: 'team' }, + { id: 'acc-1', type: 'user' }, + ], + tags: ['prod', 'cpu'], + extraProperties: { region: 'eu', attempts: '3' }, + }); + expect(result).toEqual({ + request_id: 'req-1', + processed: true, + success: true, + action: 'Create', + status: 'Created alert', + alert_id: 'alert-1', + alias: 'web-01-cpu', + processed_at: '2026-09-28T08:15:01.000Z', + }); + }); + + it('uses the Opsgenie shape on API key connections: details, user and names', async () => { + mockApi((request) => { + if (request.method === 'POST' && request.url === 'https://api.eu.opsgenie.com/v2/alerts') { + return accepted('req-2'); + } + if (request.url === 'https://api.eu.opsgenie.com/v2/alerts/requests/req-2') { + return { status: 200, body: { data: { success: true, action: 'Create', alertId: 'og-1', status: 'Created alert' }, took: 0.1 } }; + } + return undefined; + }); + + const result = await settle( + runCreate({ + auth: keyAuth(KEY_HOSTS.opsgenieEu), + propsValue: { + responderNames: [ + { type: 'team', name: 'Ops' }, + { type: 'user', name: 'jane@acme.com' }, + ], + extraProperties: { region: 'eu' }, + user: 'Activepieces', + }, + }), + ); + + const create = findRequest({ method: 'POST', url: 'https://api.eu.opsgenie.com/v2/alerts' }); + expect(create.headers?.['Authorization']).toBe(`GenieKey ${FAKE_ALERT_KEY}`); + expect(create.body).toEqual({ + message: 'CPU high', + priority: 'P3', + user: 'Activepieces', + responders: [ + { name: 'Ops', type: 'team' }, + { username: 'jane@acme.com', type: 'user' }, + ], + details: { region: 'eu' }, + }); + expect(result).toMatchObject({ alert_id: 'og-1', processed: true, success: true }); + }); + + it('keeps polling while the request is not processed yet, then returns processed false', async () => { + mockApi((request) => { + if (request.method === 'POST') { + return accepted('req-3'); + } + return { status: 404, body: { message: 'Request not found. It might not be processed, yet.' } }; + }); + + const result = await settle(runCreate({ auth: keyAuth(KEY_HOSTS.jsm), propsValue: {} })); + + const polls = requests().filter((request) => request.url.endsWith('/alerts/requests/req-3')); + expect(polls).toHaveLength(5); + expect(polls.every((request) => request.timeout !== undefined && request.timeout <= 5_000)).toBe(true); + expect(result).toMatchObject({ request_id: 'req-3', processed: false, success: null, alert_id: null }); + }); + + it('stays within about 30 seconds of polling', () => { + const waits = [500, 1_000, 2_000, 3_000, 4_000].reduce((total, delay) => total + delay, 0); + expect(waits + 5 * 4_000).toBeLessThanOrEqual(30_500); + }); + + it('fails with the vendor status when processing failed', async () => { + mockApi((request) => { + if (request.method === 'POST') { + return accepted('req-4'); + } + return processed({ requestId: 'req-4', alertId: '', success: false, status: 'Responder team does not exist' }); + }); + + const message = await settleError(runCreate({ auth: ACCOUNT_AUTH, propsValue: {} })); + + expect(message).toBe('JSM Operations could not create the alert: Responder team does not exist.'); + }); + + it('explains plan-limited actions instead of showing only the raw vendor text', async () => { + mockApi((request) => { + if (request.method === 'POST') { + return accepted('req-plan'); + } + return processed({ + requestId: 'req-plan', + alertId: '', + success: false, + status: 'Your account plan does not support alert assign action..', + }); + }); + + const message = await settleError(runCreate({ auth: ACCOUNT_AUTH, propsValue: {} })); + + expect(message).toContain('Your account plan does not support alert assign action.'); + expect(message).not.toContain('action...'); + expect(message).toContain('not included in your Jira Service Management plan'); + }); + + it('surfaces validation errors from the API', async () => { + mockApi(() => ({ status: 422, body: { errors: [{ title: 'message should not exceed 130 characters', code: 'x' }] } })); + + const message = await settleError(runCreate({ auth: ACCOUNT_AUTH, propsValue: {} })); + + expect(message).toContain('HTTP 422): message should not exceed 130 characters'); + }); + + it('rejects name-based responders on account connections', async () => { + const message = await settleError( + runCreate({ auth: ACCOUNT_AUTH, propsValue: { responderNames: [{ type: 'team', name: 'Ops' }] } }), + ); + + expect(message).toMatch(/only work with API key connections/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('key-route alert actions', () => { + it('acknowledges by alias with identifierType on the API key route', async () => { + mockApi((request) => { + if (request.method === 'POST') { + return accepted('req-a'); + } + return { status: 200, body: { data: { success: true, action: 'Acknowledge', alertId: 'og-9', status: 'Acknowledged' } } }; + }); + + const result = await settle( + acknowledgeAlertAction.run( + makeContext({ + auth: keyAuth(KEY_HOSTS.jsm), + propsValue: { alert: 'web-01-cpu', identifierType: 'alias', note: 'On it', user: 'Bot', source: undefined }, + }), + ), + ); + + const ack = findRequest({ method: 'POST', url: 'https://api.atlassian.com/jsm/ops/integration/v2/alerts/web-01-cpu/acknowledge' }); + expect(ack.queryParams).toEqual({ identifierType: 'alias' }); + expect(ack.body).toEqual({ note: 'On it', user: 'Bot' }); + expect(ack.headers?.['Authorization']).toBe(`GenieKey ${FAKE_ALERT_KEY}`); + expect(result).toMatchObject({ processed: true, success: true, alert_id: 'og-9', note_added: true, note_error: null }); + }); + + it('closes on the account route by resolving the alias, then adds the note', async () => { + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/alerts/alias`) { + return { status: 200, body: API_ALERT }; + } + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/close`) { + return accepted('req-c'); + } + if (request.url === `${ACCOUNT_BASE}/alerts/requests/req-c`) { + return processed({ requestId: 'req-c', alertId: 'alert-1', success: true, status: 'Closed' }); + } + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/notes`) { + return { status: 200, body: { id: 'note-1', note: 'Fixed' } }; + } + return undefined; + }); + + const result = await settle( + closeAlertAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { alert: 'web-01-cpu', identifierType: 'alias', note: 'Fixed', user: undefined, source: undefined }, + }), + ), + ); + + expect(findRequest({ method: 'GET', url: `${ACCOUNT_BASE}/alerts/alias` }).queryParams).toEqual({ alias: 'web-01-cpu' }); + expect(findRequest({ method: 'POST', url: `${ACCOUNT_BASE}/alerts/alert-1/close` }).body).toBeUndefined(); + expect(findRequest({ method: 'POST', url: `${ACCOUNT_BASE}/alerts/alert-1/notes` }).body).toEqual({ note: 'Fixed' }); + expect(result).toMatchObject({ alert_id: 'alert-1', success: true, note_added: true, note_error: null }); + expect(objectKeys(result)).toEqual(fieldKeys(closeAlertAction.outputSchema?.fields)); + }); + + it('still reports the close when the follow-up note fails, instead of failing the step', async () => { + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/close`) { + return accepted('req-c'); + } + if (request.url === `${ACCOUNT_BASE}/alerts/requests/req-c`) { + return processed({ requestId: 'req-c', alertId: 'alert-1', success: true, status: 'Closed' }); + } + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/notes`) { + return { status: 500, body: { message: 'Internal error' } }; + } + return undefined; + }); + + const result = await settle( + closeAlertAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { alert: 'alert-1', identifierType: 'id', note: 'Fixed', user: undefined, source: undefined }, + }), + ), + ); + + expect(result).toMatchObject({ alert_id: 'alert-1', success: true, note_added: false }); + expect(result).toHaveProperty('note_error', expect.stringContaining('The alert was updated, but the note could not be added')); + }); + + it('leaves the note fields empty when no note is given', async () => { + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/acknowledge`) { + return accepted('req-a'); + } + if (request.url === `${ACCOUNT_BASE}/alerts/requests/req-a`) { + return processed({ requestId: 'req-a', alertId: 'alert-1', success: true, status: 'Acknowledged' }); + } + return undefined; + }); + + const result = await settle( + acknowledgeAlertAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { alert: 'alert-1', identifierType: 'id', note: undefined, user: undefined, source: undefined }, + }), + ), + ); + + expect(result).toMatchObject({ success: true, note_added: null, note_error: null }); + expect(requests().some((request) => request.url.endsWith('/notes'))).toBe(false); + }); + + it('adds a note synchronously on the account route and asynchronously with a key', async () => { + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/alerts/alert-1/notes`) { + return { status: 200, body: { id: 'note-1', note: 'Checked logs', createdAt: '2026-09-28T09:00:00Z' } }; + } + if (request.url === 'https://api.opsgenie.com/v2/alerts/alert-1/notes') { + return accepted('req-n'); + } + if (request.url === 'https://api.opsgenie.com/v2/alerts/requests/req-n') { + return { status: 200, body: { data: { success: true, action: 'AddNote', alertId: 'alert-1' } } }; + } + return undefined; + }); + const propsValue = { alert: 'alert-1', identifierType: 'id', note: 'Checked logs', user: undefined, source: undefined }; + + const account = await settle( + addNoteAction.run(makeContext({ auth: ACCOUNT_AUTH, propsValue })), + ); + const key = await settle( + addNoteAction.run(makeContext({ auth: keyAuth(KEY_HOSTS.opsgenieUs), propsValue })), + ); + + expect(account).toMatchObject({ note_id: 'note-1', request_id: null, processed: true, alert_id: 'alert-1' }); + expect(key).toMatchObject({ note_id: null, request_id: 'req-n', processed: true, alert_id: 'alert-1' }); + expect(findRequest({ method: 'POST', url: 'https://api.opsgenie.com/v2/alerts/alert-1/notes' }).queryParams).toEqual({ identifierType: 'id' }); + expect(objectKeys(account)).toEqual(fieldKeys(addNoteAction.outputSchema?.fields)); + expect(objectKeys(key)).toEqual(fieldKeys(addNoteAction.outputSchema?.fields)); + }); +}); + +describe('account-only actions', () => { + it.each([ + ['Get Alert', () => getAlertAction.run(makeContext({ auth: keyAuth(KEY_HOSTS.jsm), propsValue: { alert: 'a', identifierType: 'id' } }))], + [ + 'Find Alerts', + () => + findAlertsAction.run( + makeContext({ + auth: keyAuth(KEY_HOSTS.opsgenieUs), + propsValue: { query: undefined, sort: undefined, order: undefined, limit: undefined, offset: undefined }, + }), + ), + ], + [ + 'Add Tags to Alert', + () => addTagsAction.run(makeContext({ auth: keyAuth(KEY_HOSTS.jsm), propsValue: { alert: 'a', identifierType: 'id', tags: ['x'] } })), + ], + [ + 'Get Who Is On Call', + () => getOnCallAction.run(makeContext({ auth: keyAuth(KEY_HOSTS.jsm), propsValue: { scheduleId: 's', date: undefined } })), + ], + ])('%s refuses an API key connection with a clear message', async (feature, run) => { + const message = await settleError(run()); + + expect(message).toContain(`${feature} needs an Atlassian account connection`); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('gets an alert by alias and flattens it to the output schema keys', async () => { + mockApi((request) => (request.url === `${ACCOUNT_BASE}/alerts/alias` ? { status: 200, body: API_ALERT } : undefined)); + + const result = await getAlertAction.run( + makeContext({ auth: ACCOUNT_AUTH, propsValue: { alert: 'web-01-cpu', identifierType: 'alias' } }), + ); + + expect(result).toMatchObject({ + id: 'alert-1', + tags: 'prod, cpu', + responders: 'team:team-1', + owner: null, + last_occurred_at: '2026-09-28T08:15:30.000Z', + extra_properties: { region: 'eu' }, + }); + expect(objectKeys(result)).toEqual(fieldKeys(getAlertAction.outputSchema?.fields)); + }); + + it('builds the Find Alerts query and clamps the page size', async () => { + mockApi((request) => (request.url === `${ACCOUNT_BASE}/alerts` ? { status: 200, body: { values: [API_ALERT], count: 1 } } : undefined)); + + const result = await findAlertsAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { query: ' status: open ', sort: 'priority', order: 'asc', limit: 500, offset: 20 }, + }), + ); + + expect(findRequest({ method: 'GET', url: `${ACCOUNT_BASE}/alerts` }).queryParams).toEqual({ + query: 'status: open', + sort: 'priority', + order: 'asc', + size: '100', + offset: '20', + }); + const schema = findAlertsAction.outputSchema; + expect(schema?.fields[0].value).toBe(''); + expect(objectKeys(firstItem(result))).toEqual(fieldKeys(schema?.fields[0].listItems)); + }); + + it('omits an empty query and falls back to safe defaults', async () => { + mockApi(() => ({ status: 200, body: { values: [] } })); + + await findAlertsAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { query: ' ', sort: 'bogus', order: undefined, limit: 0, offset: -5 }, + }), + ); + + expect(requests()[0].queryParams).toEqual({ sort: 'createdAt', order: 'desc', size: '1', offset: '0' }); + }); + + it('removes tags with a DELETE body and assigns by account ID', async () => { + mockApi((request) => { + if (request.url.endsWith('/requests/req-t')) { + return processed({ requestId: 'req-t', alertId: 'alert-1', success: true, status: 'ok' }); + } + return accepted('req-t'); + }); + + await settle( + removeTagsAction.run( + makeContext({ auth: ACCOUNT_AUTH, propsValue: { alert: 'alert-1', identifierType: 'id', tags: ['prod', 'cpu'] } }), + ), + ); + await settle( + assignAlertAction.run( + makeContext({ auth: ACCOUNT_AUTH, propsValue: { alert: 'alert-1', identifierType: 'id', accountId: 'acc-7' } }), + ), + ); + + expect(findRequest({ method: 'DELETE', url: `${ACCOUNT_BASE}/alerts/alert-1/tags` }).body).toEqual({ tags: ['prod', 'cpu'] }); + expect(findRequest({ method: 'POST', url: `${ACCOUNT_BASE}/alerts/alert-1/assign` }).body).toEqual({ accountId: 'acc-7' }); + }); + + it('updates only the filled fields with one PATCH each', async () => { + mockApi((request) => { + if (request.method === 'PATCH') { + return accepted(`req-${request.url.split('/').pop()}`); + } + const requestId = request.url.split('/').pop() ?? ''; + return processed({ requestId, alertId: 'alert-1', success: true, status: 'Updated' }); + }); + + const result = await settle( + updateAlertAction.run( + makeContext({ + auth: ACCOUNT_AUTH, + propsValue: { alert: 'alert-1', identifierType: 'id', message: 'New msg', description: undefined, priority: 'P1' }, + }), + ), + ); + + expect(findRequest({ method: 'PATCH', url: `${ACCOUNT_BASE}/alerts/alert-1/message` }).body).toEqual({ message: 'New msg' }); + expect(findRequest({ method: 'PATCH', url: `${ACCOUNT_BASE}/alerts/alert-1/priority` }).body).toEqual({ priority: 'P1' }); + expect(requests().some((request) => request.url.endsWith('/description'))).toBe(false); + expect(result).toMatchObject({ alert_id: 'alert-1', updated_fields: 'message, priority', processed: true }); + expect(objectKeys(result)).toEqual(fieldKeys(updateAlertAction.outputSchema?.fields)); + }); + + it('flattens on-call participants and collects user IDs', async () => { + mockApi((request) => + request.url === `${ACCOUNT_BASE}/schedules/sched-1/on-calls` + ? { + status: 200, + body: { + onCallParticipants: [ + { id: 'team-1', type: 'team' }, + { id: 'acc-1', type: 'user' }, + { id: 'esc-1', type: 'escalation', onCallParticipants: [{ id: 'acc-2', type: 'user', forwardedFrom: { id: 'acc-3', type: 'user' } }] }, + ], + }, + } + : undefined, + ); + + const result = await getOnCallAction.run( + makeContext({ auth: ACCOUNT_AUTH, propsValue: { scheduleId: 'sched-1', date: '2026-09-28T10:00:00Z' } }), + ); + + expect(findRequest({ method: 'GET', url: `${ACCOUNT_BASE}/schedules/sched-1/on-calls` }).queryParams).toEqual({ + flat: 'false', + date: '2026-09-28T10:00:00.000Z', + }); + expect(result).toMatchObject({ on_call_user_ids: 'acc-1, acc-2', on_call_user_count: 2 }); + expect(participantAt({ result, index: 3 })).toEqual({ + id: 'acc-2', + type: 'user', + name: null, + parent_id: 'esc-1', + parent_type: 'escalation', + forwarded_from_id: 'acc-3', + }); + expect(objectKeys(result)).toEqual(fieldKeys(getOnCallAction.outputSchema?.fields)); + }); +}); + +describe('classification', () => { + it('tags Close Alert as destructive because a closed alert cannot be reopened', () => { + expect(closeAlertAction.classification).toBe('DESTRUCTIVE'); + }); +}); + +describe('Custom API Call', () => { + const customApiCallAction = jsmOperations.actions()['custom_api_call']; + + const propsValue = { + url: { url: '/alerts/count' }, + method: HttpMethod.GET, + headers: {}, + queryParams: {}, + body_type: 'none', + body: undefined, + response_is_binary: false, + failsafe: false, + timeout: 10, + followRedirects: false, + }; + + it('is tagged for humans as an arbitrary write', () => { + expect(customApiCallAction).toMatchObject({ classification: 'WRITE', audience: 'human', aiMetadata: { idempotent: false } }); + }); + + it('resolves the Cloud ID before calling a relative path on an account connection', async () => { + mockApi(() => ({ status: 200, body: { count: 3 } })); + + await customApiCallAction.run(makeContext({ auth: ACCOUNT_AUTH_NO_CLOUD_ID, propsValue })); + + const call = requests().find((request) => request.url.includes('/alerts/count')); + expect(call?.url).toBe(`${ACCOUNT_BASE}/alerts/count`); + expect(call?.headers?.['Authorization']).toMatch(/^Basic /); + }); + + it('calls the key host with the GenieKey header', async () => { + mockApi(() => ({ status: 200, body: { data: { count: 3 } } })); + + await customApiCallAction.run(makeContext({ auth: keyAuth(KEY_HOSTS.opsgenieUs), propsValue })); + + expect(requests()[0].url).toBe('https://api.opsgenie.com/v2/alerts/count'); + expect(requests()[0].headers?.['Authorization']).toBe(`GenieKey ${FAKE_ALERT_KEY}`); + }); + + it('accepts a full URL under the connection base URL', async () => { + mockApi(() => ({ status: 200, body: { count: 3 } })); + + await customApiCallAction.run( + makeContext({ auth: ACCOUNT_AUTH, propsValue: { ...propsValue, url: { url: `${ACCOUNT_BASE}/alerts/count` } } }), + ); + + expect(requests()[0].url).toBe(`${ACCOUNT_BASE}/alerts/count`); + }); + + it.each([ + ['another host', 'https://attacker.example/collect'], + ['a lookalike host', 'https://api.atlassian.com.attacker.example/jsm/ops/api/x/v1/alerts'], + ['plain http', `http://api.atlassian.com/jsm/ops/api/${CLOUD_ID}/v1/alerts`], + ['another Atlassian API', 'https://api.atlassian.com/ex/jira/x/rest/api/3/myself'], + ['another cloud ID', 'https://api.atlassian.com/jsm/ops/api/other-cloud/v1/alerts'], + ['a path that climbs out of the base', `${ACCOUNT_BASE}/../../other/v1/alerts`], + ['embedded credentials', `https://user:pass@api.atlassian.com/jsm/ops/api/${CLOUD_ID}/v1/alerts`], + ['a protocol-relative URL', '//attacker.example/collect'], + ])('refuses %s without sending the account credentials', async (_label, url) => { + mockApi(() => ({ status: 200, body: {} })); + + await expect( + customApiCallAction.run(makeContext({ auth: ACCOUNT_AUTH, propsValue: { ...propsValue, url: { url } } })), + ).rejects.toThrow(/credentials|not a valid URL/); + expect(requests()).toHaveLength(0); + }); + + it('keeps an API key on the host picked for the connection', async () => { + mockApi(() => ({ status: 200, body: {} })); + + await expect( + customApiCallAction.run( + makeContext({ auth: keyAuth(KEY_HOSTS.opsgenieEu), propsValue: { ...propsValue, url: { url: 'https://api.opsgenie.com/v2/alerts' } } }), + ), + ).rejects.toThrow(`only sends this connection's credentials to ${KEY_HOSTS.opsgenieEu}/v2`); + expect(requests()).toHaveLength(0); + }); +}); + +describe('New Alert trigger', () => { + const props = { query: 'priority: P1' }; + + it('polls oldest-first for alerts created after the last poll and returns only new ones', async () => { + mockApi((request) => + request.url === `${ACCOUNT_BASE}/alerts` + ? { + status: 200, + body: { + values: [ + { ...API_ALERT, id: 'old', createdAt: '2026-09-28T08:00:00.000Z' }, + { ...API_ALERT, id: 'new', createdAt: '2026-09-28T08:20:00.000Z' }, + ], + }, + } + : undefined, + ); + const since = Date.parse('2026-09-28T08:10:00.000Z'); + const store = memoryStore({ lastPoll: since }); + + const items = await pollingTrigger().run(makeTriggerContext({ auth: ACCOUNT_AUTH, propsValue: props, store })); + + expect(requests()[0].queryParams).toEqual({ + query: `createdAt > ${since} AND (priority: P1)`, + sort: 'createdAt', + order: 'asc', + size: '100', + offset: '0', + }); + expect(items).toHaveLength(1); + expect(firstItem(items)).toMatchObject({ id: 'new' }); + expect(store.data.get('lastPoll')).toBe(Date.parse('2026-09-28T08:20:00.000Z')); + expect(objectKeys(firstItem(items))).toEqual(fieldKeys(newAlertTrigger.outputSchema?.fields)); + }); + + it('pages up to five full pages and then stops', async () => { + const page = Array.from({ length: 100 }, (_, index) => ({ ...API_ALERT, id: `a${index}`, createdAt: new Date(Date.parse('2026-09-28T09:00:00Z') + index).toISOString() })); + mockApi(() => ({ status: 200, body: { values: page } })); + + await pollingTrigger().run( + makeTriggerContext({ auth: ACCOUNT_AUTH, propsValue: { query: undefined }, store: memoryStore({ lastPoll: 1 }) }), + ); + + expect(requests().map((request) => request.queryParams?.['offset'])).toEqual(['0', '100', '200', '300', '400']); + expect(requests()[0].queryParams?.['query']).toBe('createdAt > 1'); + }); + + it('tests with the newest alerts and no time filter', async () => { + mockApi(() => ({ status: 200, body: { values: [API_ALERT] } })); + + const items = await pollingTrigger().test( + makeTriggerContext({ auth: ACCOUNT_AUTH, propsValue: { query: undefined }, store: memoryStore() }), + ); + + expect(requests()[0].queryParams).toEqual({ sort: 'createdAt', order: 'desc', size: '10', offset: '0' }); + expect(items).toHaveLength(1); + }); + + it('refuses to enable on an API key connection', async () => { + const store = memoryStore(); + + await expect( + pollingTrigger().onEnable(makeTriggerContext({ auth: keyAuth(KEY_HOSTS.jsm), propsValue: props, store })), + ).rejects.toThrow(/The New Alert trigger needs an Atlassian account connection/); + expect(store.data.has('lastPoll')).toBe(false); + }); + + it('stores the checkpoint when enabled on an account connection', async () => { + const store = memoryStore(); + + await pollingTrigger().onEnable(makeTriggerContext({ auth: ACCOUNT_AUTH, propsValue: props, store })); + + expect(typeof store.data.get('lastPoll')).toBe('number'); + }); +}); + +describe('dropdowns', () => { + it('lists teams by name', async () => { + mockApi((request) => + request.url === `${ACCOUNT_BASE}/teams` ? { status: 200, body: [{ teamId: 't1', teamName: 'Ops' }] } : undefined, + ); + + const state = await jsmOpsProps.teams({ displayName: 'Teams', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext()); + + expect(state).toEqual({ disabled: false, options: [{ label: 'Ops', value: 't1' }], placeholder: undefined }); + }); + + it('lists escalations across teams, labelled with the team', async () => { + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/teams`) { + return { status: 200, body: { platformTeams: [{ teamId: 't1', teamName: 'Ops' }, { teamId: 't2', teamName: 'DB' }] } }; + } + if (request.url === `${ACCOUNT_BASE}/teams/t1/escalations`) { + return { status: 200, body: { values: [{ id: 'e1', name: 'Ops_escalation' }] } }; + } + if (request.url === `${ACCOUNT_BASE}/teams/t2/escalations`) { + return { status: 200, body: { values: [] } }; + } + return undefined; + }); + + const state = await jsmOpsProps.escalation({ displayName: 'Escalation', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext()); + + expect(state.options).toEqual([{ label: 'Ops_escalation (Ops)', value: 'e1' }]); + }); + + it('lists escalations from every team and pages each team past 100', async () => { + const teams = Array.from({ length: 30 }, (_, index) => ({ teamId: `t${index}`, teamName: `Team ${index}` })); + const full = Array.from({ length: 100 }, (_, index) => ({ id: `e${index}`, name: `E${index}` })); + mockApi((request) => { + if (request.url === `${ACCOUNT_BASE}/teams`) { + return { status: 200, body: teams }; + } + if (request.url === `${ACCOUNT_BASE}/teams/t0/escalations`) { + return { status: 200, body: { values: request.queryParams?.['offset'] === '0' ? full : [{ id: 'e-last', name: 'Last' }] } }; + } + if (request.url === `${ACCOUNT_BASE}/teams/t29/escalations`) { + return { status: 200, body: { values: [{ id: 'e-t29', name: 'Night' }] } }; + } + return { status: 200, body: { values: [] } }; + }); + + const state = await jsmOpsProps.escalation({ displayName: 'Escalation', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext()); + + expect(state.options).toHaveLength(102); + expect(state.options).toContainEqual({ label: 'Last (Team 0)', value: 'e-last' }); + expect(state.options).toContainEqual({ label: 'Night (Team 29)', value: 'e-t29' }); + expect(requests().filter((request) => request.url === `${ACCOUNT_BASE}/teams/t0/escalations`).map((request) => request.queryParams?.['offset'])).toEqual(['0', '100']); + }); + + it('pages schedules and shows the time zone', async () => { + const full = Array.from({ length: 50 }, (_, index) => ({ id: `s${index}`, name: `S${index}`, timezone: 'UTC' })); + mockApi((request) => + request.queryParams?.['offset'] === '0' ? { status: 200, body: { values: full } } : { status: 200, body: { values: [{ id: 'last', name: 'Last' }] } }, + ); + + const state = await jsmOpsProps.schedule({ displayName: 'Schedule', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext()); + + expect(state.options).toHaveLength(51); + expect(state.options[0]).toEqual({ label: 'S0 (UTC)', value: 's0' }); + expect(requests().map((request) => request.queryParams?.['offset'])).toEqual(['0', '50']); + }); + + it('searches Jira users on the site and keeps only active people', async () => { + mockApi((request) => + request.url === `${SITE}/rest/api/3/user/search` + ? { + status: 200, + body: [ + { accountId: 'acc-1', accountType: 'atlassian', displayName: 'Jane', emailAddress: 'jane@acme.com', active: true }, + { accountId: 'bot', accountType: 'app', displayName: 'Bot', active: true }, + { accountId: 'gone', accountType: 'atlassian', displayName: 'Gone', active: false }, + ], + } + : undefined, + ); + + const state = await jsmOpsProps.user({ displayName: 'User', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext('jan')); + + expect(state.options).toEqual([{ label: 'Jane (jane@acme.com)', value: 'acc-1' }]); + const search = findRequest({ method: 'GET', url: `${SITE}/rest/api/3/user/search` }); + expect(search.queryParams).toEqual({ query: 'jan', maxResults: '50' }); + expect(search.followRedirects).toBe(false); + }); + + it('lists responders by the chosen type', async () => { + mockApi((request) => + request.url === `${ACCOUNT_BASE}/teams` ? { status: 200, body: { platformTeams: [{ teamId: 't1', teamName: 'Ops' }] } } : undefined, + ); + const dropdown = jsmOpsProps.responder(); + + const teamsState = await dropdown.options({ auth: ACCOUNT_AUTH, responderType: 'team' }, propertyContext()); + const emptyState = await dropdown.options({ auth: ACCOUNT_AUTH, responderType: undefined }, propertyContext()); + + expect(teamsState.options).toEqual([{ label: 'Ops', value: 't1' }]); + expect(emptyState).toMatchObject({ disabled: true, placeholder: 'Pick a responder type first.' }); + }); + + it('is disabled with a hint on API key connections and before connecting', async () => { + const dropdown = jsmOpsProps.teams({ displayName: 'Teams', description: 'd' }); + + const keyState = await dropdown.options({ auth: keyAuth(KEY_HOSTS.jsm) }, propertyContext()); + const noAuth = await dropdown.options({}, propertyContext()); + + expect(keyState).toMatchObject({ disabled: true, placeholder: expect.stringContaining('Atlassian Account connection') }); + expect(noAuth).toMatchObject({ disabled: true }); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('shows the API error in the placeholder instead of throwing', async () => { + mockApi(() => ({ status: 403, body: { errors: [{ title: 'Forbidden', code: 'x' }] } })); + + const state = await jsmOpsProps.teams({ displayName: 'Teams', description: 'd' }).options({ auth: ACCOUNT_AUTH }, propertyContext()); + + expect(state).toMatchObject({ disabled: true, placeholder: expect.stringContaining('HTTP 403): Forbidden') }); + }); +}); + +describe('output schemas', () => { + it('matches what the write actions return', async () => { + mockApi((request) => { + if (request.method === 'POST') { + return accepted('req-s'); + } + return processed({ requestId: 'req-s', alertId: 'alert-1', success: true, status: 'ok' }); + }); + + const result = await settle( + addTagsAction.run(makeContext({ auth: ACCOUNT_AUTH, propsValue: { alert: 'alert-1', identifierType: 'id', tags: ['x'] } })), + ); + + expect(objectKeys(result)).toEqual(fieldKeys(addTagsAction.outputSchema?.fields)); + expect(fieldKeys(createAlertAction.outputSchema?.fields)).toEqual(objectKeys(result)); + }); +}); + +type JsmConnection = AppConnectionValueForAuthProperty; + +type Request = { + method: string; + url: string; + headers?: Record; + queryParams?: Record; + body?: unknown; + timeout?: number; + followRedirects?: boolean; +}; + +type Reply = { status: number; body: unknown }; + +type Handler = (request: Request) => Reply | undefined; + +type ProcessedParams = { requestId: string; alertId: string; success: boolean; status: string }; + +type Settled = { ok: true; value: T } | { ok: false; error: unknown }; + +type TriggerContextParams = { + propsValue: StaticPropsValue; + auth: JsmConnection; + store: ReturnType; +}; diff --git a/packages/pieces/community/jsm-operations/tsconfig.json b/packages/pieces/community/jsm-operations/tsconfig.json new file mode 100644 index 000000000000..71bc5814f5de --- /dev/null +++ b/packages/pieces/community/jsm-operations/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} diff --git a/packages/pieces/community/jsm-operations/tsconfig.lib.json b/packages/pieces/community/jsm-operations/tsconfig.lib.json new file mode 100644 index 000000000000..a648eb45d7fa --- /dev/null +++ b/packages/pieces/community/jsm-operations/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "paths": {}, + "outDir": "./dist", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/pieces/community/jsm-operations/vitest.config.ts b/packages/pieces/community/jsm-operations/vitest.config.ts new file mode 100644 index 000000000000..79c972619047 --- /dev/null +++ b/packages/pieces/community/jsm-operations/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'), + }, + }, +}) diff --git a/packages/pieces/community/ntfy/package.json b/packages/pieces/community/ntfy/package.json index d6fccaffa271..4c817b774c2a 100644 --- a/packages/pieces/community/ntfy/package.json +++ b/packages/pieces/community/ntfy/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-ntfy", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "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" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -15,6 +16,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { + "vitest": "3.2.6", "tslib": "2.6.2" } } diff --git a/packages/pieces/community/ntfy/src/i18n/translation.json b/packages/pieces/community/ntfy/src/i18n/translation.json index c43197ffdd4e..d02af647b79a 100644 --- a/packages/pieces/community/ntfy/src/i18n/translation.json +++ b/packages/pieces/community/ntfy/src/i18n/translation.json @@ -6,8 +6,30 @@ "Ntfy Access Token": "Ntfy Access Token", "\n To obtain a token:\n\n 1. Log in to your Ntfy instance.\n 2. Click on Account\n 3. Go under, on Access tokens and click on the button icon to copy your Token or CREATE ACCESS TOKEN if you do not have\n 4. Please pay attention to the expiration time when copying/creating a Token.\n 4. Copy your access token & and paste them into the fields below.\n ": "\n To obtain a token:\n\n 1. Log in to your Ntfy instance.\n 2. Click on Account\n 3. Go under, on Access tokens and click on the button icon to copy your Token or CREATE ACCESS TOKEN if you do not have\n 4. Please pay attention to the expiration time when copying/creating a Token.\n 4. Copy your access token & and paste them into the fields below.\n ", "Send Notification": "Send Notification", + "Send File": "Send File", + "Update Notification": "Update Notification", + "Clear Notification": "Clear Notification", + "Delete Notification": "Delete Notification", + "Fetch Messages": "Fetch Messages", + "List Scheduled Messages": "List Scheduled Messages", + "Publish Message": "Publish Message", + "Get Account": "Get Account", + "Get Server Stats": "Get Server Stats", + "Check Server Health": "Check Server Health", + "Get Attachment Info": "Get Attachment Info", "Custom API Call": "Custom API Call", "Send a notification to ntfy": "Send a notification to ntfy", + "Send a notification with a file from your flow attached. ntfy.sh accepts files up to 2 MB without an account; self-hosted servers allow 15 MB by default.": "Send a notification with a file from your flow attached. ntfy.sh accepts files up to 2 MB without an account; self-hosted servers allow 15 MB by default.", + "Replace a notification already sent (or still scheduled) with new content, e.g. a progress update. Fields left empty are not copied from the original. Requires ntfy server v2.16.0 or later.": "Replace a notification already sent (or still scheduled) with new content, e.g. a progress update. Fields left empty are not copied from the original. Requires ntfy server v2.16.0 or later.", + "Mark a notification as read and dismiss it from phones and the web app. Requires ntfy server v2.16.0 or later.": "Mark a notification as read and dismiss it from phones and the web app. Requires ntfy server v2.16.0 or later.", + "Remove a notification from phones and the web app, or cancel a scheduled one before it is delivered. Requires ntfy server v2.16.0 or later.": "Remove a notification from phones and the web app, or cancel a scheduled one before it is delivered. Requires ntfy server v2.16.0 or later.", + "Read messages that the ntfy server has cached for one or more topics. A message sent a few seconds ago may not be listed yet.": "Read messages that the ntfy server has cached for one or more topics. A message sent a few seconds ago may not be listed yet.", + "List messages that are scheduled for later delivery and have not been delivered yet. A message scheduled a few seconds ago may not be listed yet.": "List messages that are scheduled for later delivery and have not been delivered yet. A message scheduled a few seconds ago may not be listed yet.", + "Publish a notification to a ntfy topic with typed fields.": "Publish a notification to a ntfy topic with typed fields.", + "Get the ntfy account behind the connection, with its limits and usage today.": "Get the ntfy account behind the connection, with its limits and usage today.", + "Get the total number of messages the ntfy server has published and its current message rate.": "Get the total number of messages the ntfy server has published and its current message rate.", + "Check that the ntfy server on the connection is up and accepts the connection's token.": "Check that the ntfy server on the connection is up and accepts the connection's token.", + "Check whether a file uploaded with a notification is still on the ntfy server, and its size.": "Check whether a file uploaded with a notification is still on the ntfy server, and its size.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "Topic": "Topic", "Title": "Title", @@ -18,14 +40,38 @@ "Actions": "Actions", "Click": "Click", "Delay": "Delay", + "Attachment URL": "Attachment URL", + "Attachment File Name": "Attachment File Name", + "Markdown": "Markdown", + "Email To": "Email To", + "Phone Call To": "Phone Call To", + "Sequence ID": "Sequence ID", + "Server Cache": "Server Cache", + "Firebase": "Firebase", + "File": "File", + "File Name": "File Name", + "Click URL": "Click URL", + "Icon URL": "Icon URL", + "Action Buttons": "Action Buttons", + "Topics": "Topics", + "Since": "Since", + "Priority Filter": "Priority Filter", + "Tags Filter": "Tags Filter", + "Title Filter": "Title Filter", + "Message Filter": "Message Filter", + "Include Clear/Delete Events": "Include Clear/Delete Events", + "Limit": "Limit", + "Do Not Cache": "Do Not Cache", + "Do Not Forward to Firebase": "Do Not Forward to Firebase", + "Message ID": "Message ID", "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", "The topic/channel to send the notification to, e.g. test1": "The topic/channel to send the notification to, e.g. test1", "The title of the notification": "The title of the notification", @@ -36,16 +82,63 @@ "Add Action buttons to notifications, see https://docs.ntfy.sh/publish/#action-buttons": "Add Action buttons to notifications, see https://docs.ntfy.sh/publish/#action-buttons", "You can define which URL to open when a notification is clicked, see https://docs.ntfy.sh/publish/#click-action": "You can define which URL to open when a notification is clicked, see https://docs.ntfy.sh/publish/#click-action", "Let ntfy send messages at a later date, e.g. 'tomorrow, 10am', see https://docs.ntfy.sh/publish/#scheduled-delivery": "Let ntfy send messages at a later date, e.g. 'tomorrow, 10am', see https://docs.ntfy.sh/publish/#scheduled-delivery", + "Public URL of a file to attach, e.g. https://example.com/report.pdf. ntfy does not download it; the apps link to it.": "Public URL of a file to attach, e.g. https://example.com/report.pdf. ntfy does not download it; the apps link to it.", + "Name shown for the attachment, e.g. report.pdf. Only used together with an attachment.": "Name shown for the attachment, e.g. report.pdf. Only used together with an attachment.", + "Render the message as Markdown (supported in the web app and on Android).": "Render the message as Markdown (supported in the web app and on Android).", + "Also send the notification to this email address, e.g. jane@example.com. ntfy.sh allows 5 emails a day without an account; with an access token, ntfy.sh only sends to addresses verified in the account settings.": "Also send the notification to this email address, e.g. jane@example.com. ntfy.sh allows 5 emails a day without an account; with an access token, ntfy.sh only sends to addresses verified in the account settings.", + "Also read the message out in a phone call to this number, e.g. +12223334444, or \"yes\" for your first verified number. Needs a paid ntfy.sh plan (or a self-hosted server with Twilio) and a verified number.": "Also read the message out in a phone call to this number, e.g. +12223334444, or \"yes\" for your first verified number. Needs a paid ntfy.sh plan (or a self-hosted server with Twilio) and a verified number.", + "Your own ID for this notification, e.g. backup-job-42, so later steps can update, clear or delete it. Letters, digits, \"-\" and \"_\". Needs ntfy server v2.16.0+.": "Your own ID for this notification, e.g. backup-job-42, so later steps can update, clear or delete it. Letters, digits, \"-\" and \"_\". Needs ntfy server v2.16.0+.", + "Leave empty to let the server cache the message (default), so it can be fetched later. \"Do not cache\" delivers it only to clients connected right now.": "Leave empty to let the server cache the message (default), so it can be fetched later. \"Do not cache\" delivers it only to clients connected right now.", + "Leave empty to also push through Firebase (default, needed for instant delivery on Google Play Android apps). \"Do not forward\" skips Firebase.": "Leave empty to also push through Firebase (default, needed for instant delivery on Google Play Android apps). \"Do not forward\" skips Firebase.", + "The topic name, e.g. backups_home. 1-64 characters: letters, digits, \"-\" and \"_\". Anyone who knows a public topic name can read it, so pick a hard-to-guess name.": "The topic name, e.g. backups_home. 1-64 characters: letters, digits, \"-\" and \"_\". Anyone who knows a public topic name can read it, so pick a hard-to-guess name.", + "The file to attach, e.g. the output of a previous step.": "The file to attach, e.g. the output of a previous step.", + "Name shown for the file, e.g. report.pdf. Defaults to the file's own name.": "Name shown for the file, e.g. report.pdf. Defaults to the file's own name.", + "Notification body, up to 4,096 bytes. Leave empty and ntfy shows \"You received a file: \".": "Notification body, up to 4,096 bytes. Leave empty and ntfy shows \"You received a file: \".", + "Optional notification title, e.g. \"Backup finished\". Defaults to the topic URL in the apps.": "Optional notification title, e.g. \"Backup finished\". Defaults to the topic URL in the apps.", + "How urgently the phone should alert. Leave empty for the default (3).": "How urgently the phone should alert. Leave empty for the default (3).", + "Tags shown with the notification. Tags that match an emoji short code (e.g. warning, white_check_mark, rotating_light) are shown as that emoji.": "Tags shown with the notification. Tags that match an emoji short code (e.g. warning, white_check_mark, rotating_light) are shown as that emoji.", + "Web page or app link opened when the notification is tapped, e.g. https://example.com/orders/42": "Web page or app link opened when the notification is tapped, e.g. https://example.com/orders/42", + "Schedule delivery for later: a duration (30m, 2h, 1 day), a Unix timestamp or natural language like 'tomorrow, 10am'. Between 10 seconds and 3 days on default servers.": "Schedule delivery for later: a duration (30m, 2h, 1 day), a Unix timestamp or natural language like 'tomorrow, 10am'. Between 10 seconds and 3 days on default servers.", + "The notification to replace: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.": "The notification to replace: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.", + "The new notification body, up to 4,096 bytes, e.g. \"Download 50% done\".": "The new notification body, up to 4,096 bytes, e.g. \"Download 50% done\".", + "URL of a JPEG or PNG image used as the notification icon, e.g. https://example.com/icon.png": "URL of a JPEG or PNG image used as the notification icon, e.g. https://example.com/icon.png", + "Up to 3 buttons as a JSON array, e.g. [{\"action\":\"view\",\"label\":\"Open\",\"url\":\"https://example.com\"}]. Types: view, http, broadcast, copy. See https://docs.ntfy.sh/publish/#action-buttons": "Up to 3 buttons as a JSON array, e.g. [{\"action\":\"view\",\"label\":\"Open\",\"url\":\"https://example.com\"}]. Types: view, http, broadcast, copy. See https://docs.ntfy.sh/publish/#action-buttons", + "The notification to clear: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.": "The notification to clear: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.", + "The notification to delete or cancel: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. reminder-42.": "The notification to delete or cancel: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. reminder-42.", + "One topic, or several separated by commas, e.g. alerts,backups_home. Letters, digits, \"-\" and \"_\" only.": "One topic, or several separated by commas, e.g. alerts,backups_home. Letters, digits, \"-\" and \"_\" only.", + "How far back to read: a duration (2h), a Unix timestamp, a message ID (messages after it), \"latest\" or \"all\". Defaults to 12h.": "How far back to read: a duration (2h), a Unix timestamp, a message ID (messages after it), \"latest\" or \"all\". Defaults to 12h.", + "Only return messages with any of these priorities. Leave empty for all.": "Only return messages with any of these priorities. Leave empty for all.", + "Only return messages that have all of these tags, e.g. backup.": "Only return messages that have all of these tags, e.g. backup.", + "Only return messages whose title is exactly this text.": "Only return messages whose title is exactly this text.", + "Only return messages whose body is exactly this text.": "Only return messages whose body is exactly this text.", + "Also return the message_clear and message_delete events created by Clear/Delete Notification.": "Also return the message_clear and message_delete events created by Clear/Delete Notification.", + "Maximum number of messages to return (newest first), 1-1000. Defaults to 100.": "Maximum number of messages to return (newest first), 1-1000. Defaults to 100.", + "Notification body, up to 4,096 bytes, e.g. \"Backup finished in 4m 12s\". ntfy uses \"triggered\" when empty (or \"You received a file: \" with an attachment).": "Notification body, up to 4,096 bytes, e.g. \"Backup finished in 4m 12s\". ntfy uses \"triggered\" when empty (or \"You received a file: \" with an attachment).", + "Whole number 1 (min) to 5 (max, urgent). Leave empty for the default 3.": "Whole number 1 (min) to 5 (max, urgent). Leave empty for the default 3.", + "Deliver only to clients connected right now; the message cannot be fetched later. Cannot be combined with Delay.": "Deliver only to clients connected right now; the message cannot be fetched later. Cannot be combined with Delay.", + "Skip Firebase push (Google Play Android apps then only get it while connected).": "Skip Firebase push (Google Play Android apps then only get it while connected).", + "ID of the message the file was uploaded with, e.g. TCZJOKUErDsd (the \"id\" returned by Send File). A trailing file extension such as .txt is ignored.": "ID of the message the file was uploaded with, e.g. TCZJOKUErDsd (the \"id\" returned by Send File). A trailing file extension such as .txt is ignored.", "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.", + "Do not cache": "Do not cache", + "Do not forward to Firebase": "Do not forward to Firebase", + "1 - Min": "1 - Min", + "2 - Low": "2 - Low", + "3 - Default": "3 - Default", + "4 - High": "4 - High", + "5 - Max (urgent)": "5 - Max (urgent)", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw" + "New Message": "New Message", + "Triggers when a message is published to a ntfy topic. Needs the server to cache messages (ntfy.sh keeps them 12 hours); messages sent with \"Do not cache\" are never seen.": "Triggers when a message is published to a ntfy topic. Needs the server to cache messages (ntfy.sh keeps them 12 hours); messages sent with \"Do not cache\" are never seen.", + "Only trigger for messages with any of these priorities. Leave empty for all.": "Only trigger for messages with any of these priorities. Leave empty for all.", + "Only trigger for messages that have all of these tags, e.g. backup.": "Only trigger for messages that have all of these tags, e.g. backup." } \ No newline at end of file diff --git a/packages/pieces/community/ntfy/src/index.ts b/packages/pieces/community/ntfy/src/index.ts index 346909ac3c19..a964265b14f4 100644 --- a/packages/pieces/community/ntfy/src/index.ts +++ b/packages/pieces/community/ntfy/src/index.ts @@ -1,55 +1,51 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { - PieceAuth, - Property, - createPiece, -} from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; +import { ntfyAuth } from './lib/auth'; +import { ntfyClient } from './lib/common/client'; import { sendNotification } from './lib/actions/send-notification'; +import { publishMessage } from './lib/actions/publish-message'; +import { updateNotification } from './lib/actions/update-notification'; +import { clearNotification } from './lib/actions/clear-notification'; +import { deleteNotification } from './lib/actions/delete-notification'; +import { sendFile } from './lib/actions/send-file'; +import { fetchMessages } from './lib/actions/fetch-messages'; +import { listScheduledMessages } from './lib/actions/list-scheduled-messages'; +import { getAccount } from './lib/actions/get-account'; +import { getServerStats } from './lib/actions/get-server-stats'; +import { checkServerHealth } from './lib/actions/check-server-health'; +import { getAttachmentInfo } from './lib/actions/get-attachment-info'; +import { newMessage } from './lib/triggers/new-message'; -export const ntfyAuth = PieceAuth.CustomAuth({ - description: ` - To obtain a token: - - 1. Log in to your Ntfy instance. - 2. Click on Account - 3. Go under, on Access tokens and click on the button icon to copy your Token or CREATE ACCESS TOKEN if you do not have - 4. Please pay attention to the expiration time when copying/creating a Token. - 4. Copy your access token & and paste them into the fields below. - `, - props: { - base_url: Property.ShortText({ - displayName: 'Server URL', - description: 'Ntfy Instance URL', - required: true, - }), - access_token: PieceAuth.SecretText({ - displayName: 'Access Token', - description: 'Ntfy Access Token', - required: false, - }), - }, - required: true, -}); +export { ntfyAuth }; export const ntfy = createPiece({ displayName: 'ntfy', description: 'Notification management made easy', logoUrl: 'https://cdn.activepieces.com/pieces/ntfy.png', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', categories: [PieceCategory.COMMUNICATION], auth: ntfyAuth, authors: ["MyWay","facferreira","la3rence","kishanprmr","MoShizzle","khaledmashaly","abuaboud"], actions: [ sendNotification, + sendFile, + updateNotification, + clearNotification, + deleteNotification, + fetchMessages, + listScheduledMessages, + publishMessage, + getAccount, + getServerStats, + checkServerHealth, + getAttachmentInfo, createCustomApiCallAction({ - baseUrl: (auth) => (auth?.props.base_url ?? ''), + baseUrl: (auth) => (auth ? ntfyClient.baseUrl(auth) : ''), auth: ntfyAuth, - authMapping: async (auth) => ({ - Authorization: `Bearer ${auth.props.access_token}`, - }), + authMapping: async (auth) => ntfyClient.authHeaders(auth), }), ], - triggers: [], + triggers: [newMessage], }); diff --git a/packages/pieces/community/ntfy/src/lib/actions/check-server-health.ts b/packages/pieces/community/ntfy/src/lib/actions/check-server-health.ts new file mode 100644 index 000000000000..3650dac6a4fc --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/check-server-health.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { serverHealthOutputSchema } from '../output-schemas'; + +export const checkServerHealth = createAction({ + auth: ntfyAuth, + name: 'ntfy_check_server_health', + classification: 'READ', + displayName: 'Check Server Health', + description: 'Check that the ntfy server on the connection is up and accepts the connection\'s token.', + audience: 'ai', + aiMetadata: { + description: + 'Calls the ntfy server\'s health endpoint with the connection\'s credentials and reports whether it is healthy. Use to confirm a self-hosted server is reachable before publishing; an invalid access token makes it fail with HTTP 401. Read-only and idempotent.', + idempotent: true, + }, + props: {}, + outputSchema: serverHealthOutputSchema, + async run({ auth }) { + const response = await ntfyClient.request<{ healthy?: boolean }>({ + auth, + method: HttpMethod.GET, + path: '/v1/health', + }); + return { + healthy: response.body?.healthy === true, + server_url: ntfyClient.baseUrl(auth), + }; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/clear-notification.ts b/packages/pieces/community/ntfy/src/lib/actions/clear-notification.ts new file mode 100644 index 000000000000..55aaeacd2265 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/clear-notification.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyMessage } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { sequenceEventOutputSchema } from '../output-schemas'; + +export const clearNotification = createAction({ + auth: ntfyAuth, + name: 'clear_notification', + classification: 'WRITE', + displayName: 'Clear Notification', + description: + 'Mark a notification as read and dismiss it from phones and the web app. Requires ntfy server v2.16.0 or later.', + audience: 'both', + aiMetadata: { + description: + 'Marks a delivered notification as read and dismisses it from the notification drawer on subscribed clients, keeping it in their history. Use Delete Notification to remove it entirely or cancel a scheduled one. Needs ntfy server v2.16.0+; ntfy accepts any valid sequence ID, so a wrong ID is not reported. Idempotent: repeating it leaves the notification cleared.', + idempotent: true, + }, + props: { + topic: ntfyProps.topic(), + sequence_id: ntfyProps.sequenceId( + 'The notification to clear: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.' + ), + }, + outputSchema: sequenceEventOutputSchema, + async run({ auth, propsValue }) { + const topic = ntfyClient.validateId({ value: propsValue.topic, label: 'Topic' }); + const sequenceId = ntfyClient.validateId({ value: propsValue.sequence_id, label: 'Sequence ID' }); + const response = await ntfyClient.request({ + auth, + method: HttpMethod.PUT, + path: `/${topic}/${sequenceId}/clear`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/delete-notification.ts b/packages/pieces/community/ntfy/src/lib/actions/delete-notification.ts new file mode 100644 index 000000000000..3731b53acdda --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/delete-notification.ts @@ -0,0 +1,38 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyMessage } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { sequenceEventOutputSchema } from '../output-schemas'; + +export const deleteNotification = createAction({ + auth: ntfyAuth, + name: 'delete_notification', + classification: 'DESTRUCTIVE', + displayName: 'Delete Notification', + description: + 'Remove a notification from phones and the web app, or cancel a scheduled one before it is delivered. Requires ntfy server v2.16.0 or later.', + audience: 'both', + aiMetadata: { + description: + 'Deletes a notification by sequence ID: a scheduled message is removed from the server and never delivered, and a delivered one is removed from subscribed clients. Use Clear Notification to only mark it read. Needs ntfy server v2.16.0+; ntfy accepts any valid sequence ID, so a wrong ID is not reported. Idempotent: repeating it leaves the notification deleted.', + idempotent: true, + }, + props: { + topic: ntfyProps.topic(), + sequence_id: ntfyProps.sequenceId( + 'The notification to delete or cancel: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. reminder-42.' + ), + }, + outputSchema: sequenceEventOutputSchema, + async run({ auth, propsValue }) { + const topic = ntfyClient.validateId({ value: propsValue.topic, label: 'Topic' }); + const sequenceId = ntfyClient.validateId({ value: propsValue.sequence_id, label: 'Sequence ID' }); + const response = await ntfyClient.request({ + auth, + method: HttpMethod.DELETE, + path: `/${topic}/${sequenceId}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/fetch-messages.ts b/packages/pieces/community/ntfy/src/lib/actions/fetch-messages.ts new file mode 100644 index 000000000000..df3a3b0b686a --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/fetch-messages.ts @@ -0,0 +1,95 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { ntfyProps, PRIORITY_OPTIONS } from '../common/props'; +import { fetchMessagesOutputSchema } from '../output-schemas'; + +export const fetchMessages = createAction({ + auth: ntfyAuth, + name: 'fetch_messages', + classification: 'SEARCH', + displayName: 'Fetch Messages', + description: + 'Read messages that the ntfy server has cached for one or more topics. A message sent a few seconds ago may not be listed yet.', + audience: 'both', + aiMetadata: { + description: + 'Reads cached messages from one or more ntfy topics, newest first, optionally only since a duration, Unix timestamp or message ID, and filtered by priority, tags, exact title or exact message. "latest" returns only the newest entry, which is dropped when it is a clear/delete event and those are not included. Use to look back at what was sent; use List Scheduled Messages for pending ones. Only works while the server caches messages (12 hours on ntfy.sh); ntfy.sh writes its cache in batches, so a message published 1-3 seconds earlier may not be returned yet. Read-only and idempotent.', + idempotent: true, + }, + props: { + topics: ntfyProps.topics(), + since: Property.ShortText({ + displayName: 'Since', + description: + 'How far back to read: a duration (2h), a Unix timestamp, a message ID (messages after it), "latest" or "all". Defaults to 12h.', + required: false, + defaultValue: '12h', + }), + priority: Property.StaticMultiSelectDropdown({ + displayName: 'Priority Filter', + description: 'Only return messages with any of these priorities. Leave empty for all.', + required: false, + options: { options: PRIORITY_OPTIONS }, + }), + tags: Property.Array({ + displayName: 'Tags Filter', + description: 'Only return messages that have all of these tags, e.g. backup.', + required: false, + }), + title: Property.ShortText({ + displayName: 'Title Filter', + description: 'Only return messages whose title is exactly this text.', + required: false, + }), + message: Property.ShortText({ + displayName: 'Message Filter', + description: 'Only return messages whose body is exactly this text.', + required: false, + }), + include_events: Property.Checkbox({ + displayName: 'Include Clear/Delete Events', + description: 'Also return the message_clear and message_delete events created by Clear/Delete Notification.', + required: false, + defaultValue: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of messages to return (newest first), 1-1000. Defaults to 100.', + required: false, + defaultValue: 100, + }), + }, + outputSchema: fetchMessagesOutputSchema, + async run({ auth, propsValue }) { + const topics = ntfyClient.validateTopicList(propsValue.topics); + const since = propsValue.since?.trim() || '12h'; + const limit = propsValue.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > 1000) { + throw new Error('Limit must be a whole number from 1 to 1000.'); + } + const result = await ntfyClient.pollMessages({ + auth, + topics, + since, + filters: { + priority: ntfyClient.normalizePriorityFilter(propsValue.priority), + tags: ntfyClient.normalizeTags(propsValue.tags), + title: propsValue.title?.trim() || undefined, + message: propsValue.message?.trim() || undefined, + }, + }); + const allowedEvents = propsValue.include_events + ? ['message', 'message_clear', 'message_delete'] + : ['message']; + const matched = ntfyClient.newestFirst(result.messages.filter((m) => allowedEvents.includes(m.event))); + const messages = matched.slice(0, limit); + return { + messages, + count: messages.length, + total_matched: matched.length, + has_more: matched.length > messages.length, + server_truncated: result.truncated, + }; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/get-account.ts b/packages/pieces/community/ntfy/src/lib/actions/get-account.ts new file mode 100644 index 000000000000..e76441b04d9a --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/get-account.ts @@ -0,0 +1,90 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { accountOutputSchema } from '../output-schemas'; + +export const getAccount = createAction({ + auth: ntfyAuth, + name: 'ntfy_get_account', + classification: 'READ', + displayName: 'Get Account', + description: 'Get the ntfy account behind the connection, with its limits and usage today.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the ntfy account the connection uses (or the anonymous visitor when there is no token): username, role, tier, daily limits and how much of each was used today, plus reserved topic names. Use to check remaining message/email quota or attachment size limits before publishing. Never returns tokens, emails or phone numbers. Read-only and idempotent.', + idempotent: true, + }, + props: {}, + outputSchema: accountOutputSchema, + async run({ auth }) { + const response = await ntfyClient.request({ + auth, + method: HttpMethod.GET, + path: '/v1/account', + }); + const account = response.body ?? {}; + const limits = account.limits ?? {}; + const stats = account.stats ?? {}; + return { + username: account.username ?? null, + role: account.role ?? null, + tier_code: account.tier?.code ?? null, + tier_name: account.tier?.name ?? null, + limits_basis: limits.basis ?? null, + limits_messages: limits.messages ?? null, + limits_messages_expiry_duration: limits.messages_expiry_duration ?? null, + limits_emails: limits.emails ?? null, + limits_calls: limits.calls ?? null, + limits_reservations: limits.reservations ?? null, + limits_attachment_file_size: limits.attachment_file_size ?? null, + limits_attachment_total_size: limits.attachment_total_size ?? null, + limits_attachment_expiry_duration: limits.attachment_expiry_duration ?? null, + limits_attachment_bandwidth: limits.attachment_bandwidth ?? null, + stats_messages: stats.messages ?? null, + stats_messages_remaining: stats.messages_remaining ?? null, + stats_emails: stats.emails ?? null, + stats_emails_remaining: stats.emails_remaining ?? null, + stats_calls: stats.calls ?? null, + stats_calls_remaining: stats.calls_remaining ?? null, + stats_reservations: stats.reservations ?? null, + stats_reservations_remaining: stats.reservations_remaining ?? null, + stats_attachment_total_size: stats.attachment_total_size ?? null, + stats_attachment_total_size_remaining: stats.attachment_total_size_remaining ?? null, + reserved_topics: (account.reservations ?? []).map((r) => r.topic).join(','), + }; + }, +}); + +type AccountResponse = { + username?: string; + role?: string; + tier?: { code?: string; name?: string }; + limits?: Partial> & { basis?: string }; + stats?: Partial>; + reservations?: { topic: string }[]; +}; + +type LimitKey = + | 'messages' + | 'messages_expiry_duration' + | 'emails' + | 'calls' + | 'reservations' + | 'attachment_total_size' + | 'attachment_file_size' + | 'attachment_expiry_duration' + | 'attachment_bandwidth'; + +type StatKey = + | 'messages' + | 'messages_remaining' + | 'emails' + | 'emails_remaining' + | 'calls' + | 'calls_remaining' + | 'reservations' + | 'reservations_remaining' + | 'attachment_total_size' + | 'attachment_total_size_remaining'; diff --git a/packages/pieces/community/ntfy/src/lib/actions/get-attachment-info.ts b/packages/pieces/community/ntfy/src/lib/actions/get-attachment-info.ts new file mode 100644 index 000000000000..ef6e0549c372 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/get-attachment-info.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyRequestError } from '../common/client'; +import { attachmentInfoOutputSchema } from '../output-schemas'; + +export const getAttachmentInfo = createAction({ + auth: ntfyAuth, + name: 'ntfy_get_attachment_info', + classification: 'READ', + displayName: 'Get Attachment Info', + description: 'Check whether a file uploaded with a notification is still on the ntfy server, and its size.', + audience: 'ai', + aiMetadata: { + description: + 'Checks a file attachment that was uploaded to the ntfy server with a message (not attachment URLs) without downloading it, returning whether it still exists, its size and its download URL. Use before sharing an attachment link; uploaded files expire (3 hours by default). Read-only and idempotent.', + idempotent: true, + }, + props: { + message_id: Property.ShortText({ + displayName: 'Message ID', + description: + 'ID of the message the file was uploaded with, e.g. TCZJOKUErDsd (the "id" returned by Send File). A trailing file extension such as .txt is ignored.', + required: true, + }), + }, + outputSchema: attachmentInfoOutputSchema, + async run({ auth, propsValue }) { + const messageId = ntfyClient.validateId({ + value: (propsValue.message_id ?? '').trim().replace(/\.[A-Za-z0-9]{1,16}$/, ''), + label: 'Message ID', + }); + const url = `${ntfyClient.baseUrl(auth)}/file/${messageId}`; + try { + const response = await ntfyClient.request({ + auth, + method: HttpMethod.HEAD, + path: `/file/${messageId}`, + }); + const length = Number(ntfyClient.firstHeader({ headers: response.headers, name: 'content-length' })); + return { + message_id: messageId, + exists: true, + size_bytes: Number.isFinite(length) ? length : null, + content_type: ntfyClient.firstHeader({ headers: response.headers, name: 'content-type' }) ?? null, + url, + }; + } catch (error) { + if (error instanceof NtfyRequestError && error.status === 404) { + return { message_id: messageId, exists: false, size_bytes: null, content_type: null, url }; + } + throw error; + } + }, +}); + diff --git a/packages/pieces/community/ntfy/src/lib/actions/get-server-stats.ts b/packages/pieces/community/ntfy/src/lib/actions/get-server-stats.ts new file mode 100644 index 000000000000..002604b6d747 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/get-server-stats.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { serverStatsOutputSchema } from '../output-schemas'; + +export const getServerStats = createAction({ + auth: ntfyAuth, + name: 'ntfy_get_server_stats', + classification: 'READ', + displayName: 'Get Server Stats', + description: 'Get the total number of messages the ntfy server has published and its current message rate.', + audience: 'ai', + aiMetadata: { + description: + 'Returns server-wide statistics for the ntfy server on the connection: total messages published and the recent average messages per second. Use to gauge server load; use Get Account for this account\'s own quota. Read-only and idempotent.', + idempotent: true, + }, + props: {}, + outputSchema: serverStatsOutputSchema, + async run({ auth }) { + const response = await ntfyClient.request<{ messages?: number; messages_rate?: number }>({ + auth, + method: HttpMethod.GET, + path: '/v1/stats', + }); + return { + messages: response.body?.messages ?? null, + messages_rate: response.body?.messages_rate ?? null, + }; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/list-scheduled-messages.ts b/packages/pieces/community/ntfy/src/lib/actions/list-scheduled-messages.ts new file mode 100644 index 000000000000..d7b4ec107cfa --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/list-scheduled-messages.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { listScheduledMessagesOutputSchema } from '../output-schemas'; + +export const listScheduledMessages = createAction({ + auth: ntfyAuth, + name: 'list_scheduled_messages', + classification: 'SEARCH', + displayName: 'List Scheduled Messages', + description: + 'List messages that are scheduled for later delivery and have not been delivered yet. A message scheduled a few seconds ago may not be listed yet.', + audience: 'both', + aiMetadata: { + description: + 'Lists the messages on one or more ntfy topics that were published with a delay and are still waiting to be delivered, soonest first. Use before cancelling one with Delete Notification or replacing it with Update Notification; use Fetch Messages for delivered ones. ntfy.sh writes its cache in batches, so a message scheduled 1-3 seconds earlier may not be listed yet. Read-only and idempotent.', + idempotent: true, + }, + props: { + topics: ntfyProps.topics(), + }, + outputSchema: listScheduledMessagesOutputSchema, + async run({ auth, propsValue }) { + const topics = ntfyClient.validateTopicList(propsValue.topics); + const result = await ntfyClient.pollMessages({ auth, topics, since: 'all', scheduled: true }); + const messages = result.messages + .filter((m) => m.event === 'message' && m.time > result.serverNow) + .sort((a, b) => a.time - b.time); + return { messages, count: messages.length, server_truncated: result.truncated }; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/publish-message.ts b/packages/pieces/community/ntfy/src/lib/actions/publish-message.ts new file mode 100644 index 000000000000..44e47413b2b5 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/publish-message.ts @@ -0,0 +1,67 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyMessage } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { publishedMessageOutputSchema } from '../output-schemas'; + +export const publishMessage = createAction({ + auth: ntfyAuth, + name: 'ntfy_publish_message', + classification: 'WRITE', + displayName: 'Publish Message', + description: 'Publish a notification to a ntfy topic with typed fields.', + audience: 'ai', + aiMetadata: { + description: + 'Publishes a push notification to one ntfy topic as JSON: message, title, priority 1-5, tags, click URL, icon, attachment URL, Markdown, up to 3 action buttons, email or phone-call forwarding, a scheduled delay (not combinable with email or disable_cache), and an optional sequence_id for later update/clear/delete. Use this to alert someone; use Update Notification to change one already sent. Not idempotent: each call publishes a new message.', + idempotent: false, + }, + props: { + topic: ntfyProps.topic(), + message: ntfyProps.optionalMessage( + 'Notification body, up to 4,096 bytes, e.g. "Backup finished in 4m 12s". ntfy uses "triggered" when empty (or "You received a file: " with an attachment).' + ), + title: ntfyProps.title(), + priority: Property.Number({ + displayName: 'Priority', + description: 'Whole number 1 (min) to 5 (max, urgent). Leave empty for the default 3.', + required: false, + }), + tags: ntfyProps.tags(), + click: ntfyProps.click(), + icon: ntfyProps.icon(), + attach: ntfyProps.attach(), + filename: ntfyProps.filename(), + markdown: ntfyProps.markdown(), + actions: ntfyProps.actions(), + delay: ntfyProps.delay(), + email: ntfyProps.email(), + call: ntfyProps.call(), + sequence_id: ntfyProps.publishSequenceId(), + disable_cache: Property.Checkbox({ + displayName: 'Do Not Cache', + description: 'Deliver only to clients connected right now; the message cannot be fetched later. Cannot be combined with Delay.', + required: false, + defaultValue: false, + }), + disable_firebase: Property.Checkbox({ + displayName: 'Do Not Forward to Firebase', + description: 'Skip Firebase push (Google Play Android apps then only get it while connected).', + required: false, + defaultValue: false, + }), + }, + outputSchema: publishedMessageOutputSchema, + async run({ auth, propsValue }) { + const body = ntfyClient.buildJsonPublishBody(propsValue); + const response = await ntfyClient.request({ + auth, + method: HttpMethod.POST, + path: '/', + headers: { 'Content-Type': 'application/json' }, + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/actions/send-file.ts b/packages/pieces/community/ntfy/src/lib/actions/send-file.ts new file mode 100644 index 000000000000..0d984e7b3038 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/send-file.ts @@ -0,0 +1,82 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyMessage } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { sentFileOutputSchema } from '../output-schemas'; + +export const sendFile = createAction({ + auth: ntfyAuth, + name: 'send_file', + classification: 'WRITE', + displayName: 'Send File', + description: + 'Send a notification with a file from your flow attached. ntfy.sh accepts files up to 2 MB without an account; self-hosted servers allow 15 MB by default.', + audience: 'human', + aiMetadata: { + description: + 'Uploads a file from the flow to the ntfy server and publishes it as a notification attachment, with an optional message, title, priority, tags and click URL. Agents attaching a file by URL should use Publish Message with an attachment URL instead. Not idempotent: each call uploads the file again and sends a new notification.', + idempotent: false, + }, + props: { + topic: ntfyProps.topic(), + file: Property.File({ + displayName: 'File', + description: 'The file to attach, e.g. the output of a previous step.', + required: true, + }), + filename: Property.ShortText({ + displayName: 'File Name', + description: 'Name shown for the file, e.g. report.pdf. Defaults to the file\'s own name.', + required: false, + }), + message: ntfyProps.optionalMessage( + 'Notification body, up to 4,096 bytes. Leave empty and ntfy shows "You received a file: ".' + ), + title: ntfyProps.title(), + priority: ntfyProps.priority(), + tags: ntfyProps.tags(), + click: ntfyProps.click(), + delay: ntfyProps.delay(), + }, + outputSchema: sentFileOutputSchema, + async run({ auth, propsValue }) { + const topic = ntfyClient.validateId({ value: propsValue.topic, label: 'Topic' }); + const file = propsValue.file; + const data = fileBytes(file); + const filename = propsValue.filename?.trim() || file.filename || 'file'; + const priority = ntfyClient.parsePriority(propsValue.priority); + const tags = ntfyClient.normalizeTags(propsValue.tags); + const message = propsValue.message?.trim() ? propsValue.message : undefined; + const title = propsValue.title?.trim() || undefined; + const click = propsValue.click?.trim() || undefined; + const delay = propsValue.delay?.trim() || undefined; + const response = await ntfyClient.request({ + auth, + method: HttpMethod.PUT, + path: `/${topic}`, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Filename': ntfyClient.encodeToRFC2047(filename), + ...(message ? { 'X-Message': ntfyClient.encodeToRFC2047(message) } : {}), + ...(title ? { 'X-Title': ntfyClient.encodeToRFC2047(title) } : {}), + ...(priority !== undefined ? { 'X-Priority': String(priority) } : {}), + ...(tags ? { 'X-Tags': ntfyClient.headerValue(tags.join(',')) } : {}), + ...(click ? { 'X-Click': ntfyClient.headerValue(click) } : {}), + ...(delay ? { 'X-Delay': ntfyClient.headerValue(delay) } : {}), + }, + body: data, + }); + return response.body; + }, +}); + +function fileBytes(file: { data?: unknown; base64?: unknown }): Buffer { + if (Buffer.isBuffer(file.data)) { + return file.data; + } + if (typeof file.base64 === 'string' && file.base64.length > 0) { + return Buffer.from(file.base64, 'base64'); + } + throw new Error('The File input is empty. Map a file from a previous step or upload one.'); +} diff --git a/packages/pieces/community/ntfy/src/lib/actions/send-notification.ts b/packages/pieces/community/ntfy/src/lib/actions/send-notification.ts index 4f2f057d48d8..8afde1cce099 100644 --- a/packages/pieces/community/ntfy/src/lib/actions/send-notification.ts +++ b/packages/pieces/community/ntfy/src/lib/actions/send-notification.ts @@ -4,21 +4,19 @@ import { httpClient, HttpMethod, } from '@activepieces/pieces-common'; -import { ntfyAuth } from '../..'; +import { ntfyAuth } from '../auth'; +import { ntfyClient } from '../common/client'; +import { ntfyProps } from '../common/props'; import { sendNotificationActionOutputSchema } from '../output-schemas'; -const encodeToRFC2047 = (text: string) => { - return `=?UTF-8?B?${Buffer.from(text, 'utf-8').toString('base64')}?=`; -}; - export const sendNotification = createAction({ auth: ntfyAuth, name: 'send_notification', classification: 'WRITE', displayName: 'Send Notification', description: 'Send a notification to ntfy', - audience: 'both', - aiMetadata: { description: 'Publishes a push notification to a ntfy topic on the configured ntfy server, optionally with a title, priority (1-5), tags, icon, click-through URL, action buttons, or scheduled delay. Choose this to alert a user or channel via ntfy. Requires a topic and message; each call sends a new notification (not idempotent).', idempotent: false }, + audience: 'human', + aiMetadata: { description: 'Publishes a push notification to a ntfy topic using ntfy\'s header format, optionally with a title, priority, tags, icon, click URL, action buttons, attachment URL, Markdown, email, sequence ID or scheduled delay. Agents should prefer Publish Message (ntfy_publish_message), which takes typed fields. Not idempotent: each call sends a new notification.', idempotent: false }, props: { topic: Property.ShortText({ displayName: 'Topic', @@ -70,6 +68,26 @@ export const sendNotification = createAction({ "Let ntfy send messages at a later date, e.g. 'tomorrow, 10am', see https://docs.ntfy.sh/publish/#scheduled-delivery", required: false, }), + attach: ntfyProps.attach(), + filename: ntfyProps.filename(), + markdown: ntfyProps.markdown(), + email: ntfyProps.email(), + call: ntfyProps.call(), + sequence_id: ntfyProps.publishSequenceId(), + cache: Property.StaticDropdown({ + displayName: 'Server Cache', + description: + 'Leave empty to let the server cache the message (default), so it can be fetched later. "Do not cache" delivers it only to clients connected right now.', + required: false, + options: { options: [{ label: 'Do not cache', value: 'no' }] }, + }), + firebase: Property.StaticDropdown({ + displayName: 'Firebase', + description: + 'Leave empty to also push through Firebase (default, needed for instant delivery on Google Play Android apps). "Do not forward" skips Firebase.', + required: false, + options: { options: [{ label: 'Do not forward to Firebase', value: 'no' }] }, + }), }, outputSchema: sendNotificationActionOutputSchema, async run({ auth, propsValue }) { @@ -77,16 +95,6 @@ export const sendNotification = createAction({ const accessToken = auth.props.access_token; const topic = propsValue.topic; - let title = propsValue.title; - let message = propsValue.message; - title = encodeToRFC2047(title as string); - message = encodeToRFC2047(message as string); - const priority = propsValue.priority; - const tags = propsValue.tags; - const icon = propsValue.icon; - const actions = propsValue.actions; - const click = propsValue.click; - const delay = propsValue.delay; return await httpClient.sendRequest({ method: HttpMethod.POST, @@ -97,16 +105,24 @@ export const sendNotification = createAction({ token: accessToken, }, }), - headers: { - 'X-Message': message, - 'X-Title': title, - 'X-Priority': priority, - 'X-Tags': tags?.join(','), - 'X-Icon': icon, - 'X-Actions': actions, - 'X-Click': click, - 'X-Delay': delay, - }, + headers: ntfyClient.buildSendNotificationHeaders({ + message: propsValue.message, + title: propsValue.title, + priority: propsValue.priority, + tags: propsValue.tags, + icon: propsValue.icon, + actions: propsValue.actions, + click: propsValue.click, + delay: propsValue.delay, + attach: propsValue.attach, + filename: propsValue.filename, + markdown: propsValue.markdown, + email: propsValue.email, + call: propsValue.call, + sequence_id: propsValue.sequence_id, + cache: propsValue.cache, + firebase: propsValue.firebase, + }), }); }, }); diff --git a/packages/pieces/community/ntfy/src/lib/actions/update-notification.ts b/packages/pieces/community/ntfy/src/lib/actions/update-notification.ts new file mode 100644 index 000000000000..505262489a29 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/actions/update-notification.ts @@ -0,0 +1,53 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyMessage } from '../common/client'; +import { ntfyProps } from '../common/props'; +import { publishedMessageOutputSchema } from '../output-schemas'; + +export const updateNotification = createAction({ + auth: ntfyAuth, + name: 'update_notification', + classification: 'WRITE', + displayName: 'Update Notification', + description: + 'Replace a notification already sent (or still scheduled) with new content, e.g. a progress update. Fields left empty are not copied from the original. Requires ntfy server v2.16.0 or later.', + audience: 'both', + aiMetadata: { + description: + 'Replaces an earlier notification on subscribed phones and web apps by publishing a new message with the same sequence ID; if the original is still scheduled, the server deletes it and schedules this one instead. The new message fully replaces the old one, so resend every field that should stay (title, tags, priority). Use for progress or status updates; use Publish Message for a new alert. Needs ntfy server v2.16.0+. Not idempotent: each call appends a new message to the topic.', + idempotent: false, + }, + props: { + topic: ntfyProps.topic(), + sequence_id: ntfyProps.sequenceId( + 'The notification to replace: the Message ID returned when it was sent, or the Sequence ID you gave it, e.g. backup-job-42.' + ), + message: ntfyProps.requiredMessage('The new notification body, up to 4,096 bytes, e.g. "Download 50% done".'), + title: ntfyProps.title(), + priority: ntfyProps.priority(), + tags: ntfyProps.tags(), + click: ntfyProps.click(), + icon: ntfyProps.icon(), + attach: ntfyProps.attach(), + filename: ntfyProps.filename(), + markdown: ntfyProps.markdown(), + actions: ntfyProps.actions(), + delay: ntfyProps.delay(), + }, + outputSchema: publishedMessageOutputSchema, + async run({ auth, propsValue }) { + const body = ntfyClient.buildJsonPublishBody({ + ...propsValue, + sequence_id: ntfyClient.validateId({ value: propsValue.sequence_id, label: 'Sequence ID' }), + }); + const response = await ntfyClient.request({ + auth, + method: HttpMethod.POST, + path: '/', + headers: { 'Content-Type': 'application/json' }, + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/ntfy/src/lib/auth.ts b/packages/pieces/community/ntfy/src/lib/auth.ts new file mode 100644 index 000000000000..f9778b9038ea --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/auth.ts @@ -0,0 +1,26 @@ +import { PieceAuth, Property } from '@activepieces/pieces-framework'; + +export const ntfyAuth = PieceAuth.CustomAuth({ + description: ` + To obtain a token: + + 1. Log in to your Ntfy instance. + 2. Click on Account + 3. Go under, on Access tokens and click on the button icon to copy your Token or CREATE ACCESS TOKEN if you do not have + 4. Please pay attention to the expiration time when copying/creating a Token. + 4. Copy your access token & and paste them into the fields below. + `, + props: { + base_url: Property.ShortText({ + displayName: 'Server URL', + description: 'Ntfy Instance URL', + required: true, + }), + access_token: PieceAuth.SecretText({ + displayName: 'Access Token', + description: 'Ntfy Access Token', + required: false, + }), + }, + required: true, +}); diff --git a/packages/pieces/community/ntfy/src/lib/common/client.ts b/packages/pieces/community/ntfy/src/lib/common/client.ts new file mode 100644 index 000000000000..8055e537410e --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/common/client.ts @@ -0,0 +1,554 @@ +import { + httpClient, + HttpHeaders, + HttpMethod, + HttpRequest, + HttpResponse, + QueryParams, +} from '@activepieces/pieces-common'; +import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { ntfyAuth } from '../auth'; + +const NTFY_ID_PATTERN = /^[-_A-Za-z0-9]{1,64}$/; +const CURSOR_OVERLAP_SECONDS = 60; + +function encodeToRFC2047(text: string): string { + return `=?UTF-8?B?${Buffer.from(text, 'utf-8').toString('base64')}?=`; +} + +function baseUrl(auth: NtfyAuthValue): string { + const raw = (auth.props.base_url ?? '').trim().replace(/\/+$/, ''); + if (!URL.canParse(raw)) { + throw new Error( + 'The Server URL on the ntfy connection is not a valid URL. Use the full address, e.g. https://ntfy.sh' + ); + } + const { protocol, search, hash, username, password } = new URL(raw); + if (protocol !== 'https:' && protocol !== 'http:') { + throw new Error('The Server URL on the ntfy connection must start with https:// or http://'); + } + if (search.length > 0 || hash.length > 0 || raw.includes('?') || raw.includes('#')) { + throw new Error( + 'The Server URL on the ntfy connection must not contain "?" or "#". Use only the server address, e.g. https://ntfy.example.com' + ); + } + if (username.length > 0 || password.length > 0) { + throw new Error( + 'The Server URL on the ntfy connection must not contain a user name or password. Put the access token in the Access Token field.' + ); + } + return raw; +} + +function authHeaders(auth: NtfyAuthValue): Record { + const token = auth.props.access_token; + if (typeof token === 'string' && token.trim().length > 0) { + return { Authorization: `Bearer ${token.trim()}` }; + } + return {}; +} + +function validateId({ value, label }: { value: unknown; label: string }): string { + const id = typeof value === 'string' ? value.trim() : ''; + if (!NTFY_ID_PATTERN.test(id)) { + throw new Error( + `${label} "${String(value ?? '')}" is not valid. ntfy only accepts 1-64 letters, digits, "-" and "_".` + ); + } + return id; +} + +function validateTopicList(value: unknown): string { + const raw = typeof value === 'string' ? value : ''; + const topics = raw + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + if (topics.length === 0) { + throw new Error('Topic is required. Separate several topics with commas, e.g. alerts,backups'); + } + return topics.map((t) => validateId({ value: t, label: 'Topic' })).join(','); +} + +function parsePriority(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const n = typeof value === 'number' ? value : Number(String(value).trim()); + if (!Number.isInteger(n) || n < 1 || n > 5) { + throw new Error(`Priority must be a whole number from 1 (min) to 5 (max), got "${String(value)}".`); + } + return n; +} + +function normalizePriorityFilter(value: unknown): number[] | undefined { + const list = toList(value); + if (list.length === 0) { + return undefined; + } + const priorities = list.map((v) => parsePriority(v)).filter((v): v is number => v !== undefined); + return priorities.length > 0 ? priorities : undefined; +} + +function normalizeTags(value: unknown): string[] | undefined { + const tags = toList(value) + .map((t) => String(t ?? '').trim()) + .filter((t) => t.length > 0); + return tags.length > 0 ? tags : undefined; +} + +function toList(value: unknown): unknown[] { + if (value === undefined || value === null || value === '') { + return []; + } + if (Array.isArray(value)) { + return value.flatMap((item) => (typeof item === 'string' ? textToList(item) : [item])); + } + if (typeof value === 'string') { + return textToList(value); + } + return [value]; +} + +function textToList(value: string): unknown[] { + const text = value.trim(); + if (text.startsWith('[')) { + const parsed = tryParseJson(text); + if (Array.isArray(parsed)) { + return parsed; + } + } + return text.split(','); +} + +function newestFirst(messages: NtfyMessage[]): NtfyMessage[] { + return [...messages].reverse().sort((a, b) => b.time - a.time); +} + +function headerValue(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (/^[\x20-\x7e]*$/.test(trimmed)) { + return value; + } + return encodeToRFC2047(trimmed); +} + +function tagsHeader(tags: unknown): string | undefined { + if (Array.isArray(tags)) { + return headerValue(tags.join(',')); + } + return headerValue(normalizeTags(tags)?.join(',')); +} + +function parseActions(value: unknown): Record[] | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'string' ? parseJsonOrThrow(value) : value; + if (!Array.isArray(parsed)) { + throw new Error('Action Buttons must be a JSON array of action objects.'); + } + if (parsed.length === 0) { + return undefined; + } + if (parsed.length > 3) { + throw new Error('ntfy allows at most 3 action buttons per notification.'); + } + return parsed.map((item) => { + if (!isRecord(item)) { + throw new Error('Each action button must be a JSON object with at least "action" and "label".'); + } + return item; + }); +} + +function buildSendNotificationHeaders(input: SendNotificationHeaderInput): HttpHeaders { + const attach = nonEmpty(input.attach); + const filename = nonEmpty(input.filename); + const email = nonEmpty(input.email); + const call = nonEmpty(input.call); + const sequenceId = nonEmpty(input.sequence_id); + return { + 'X-Message': encodeToRFC2047(input.message), + ...(typeof input.title === 'string' && input.title.length > 0 + ? { 'X-Title': encodeToRFC2047(input.title) } + : {}), + 'X-Priority': headerValue(input.priority), + 'X-Tags': tagsHeader(input.tags), + 'X-Icon': headerValue(input.icon), + 'X-Actions': headerValue(input.actions), + 'X-Click': headerValue(input.click), + 'X-Delay': headerValue(input.delay), + ...(attach ? { 'X-Attach': headerValue(attach) } : {}), + ...(filename ? { 'X-Filename': encodeToRFC2047(filename) } : {}), + ...(input.markdown === true ? { 'X-Markdown': 'yes' } : {}), + ...(email ? { 'X-Email': headerValue(email) } : {}), + ...(call ? { 'X-Call': headerValue(call) } : {}), + ...(sequenceId ? { 'X-Sequence-ID': validateId({ value: sequenceId, label: 'Sequence ID' }) } : {}), + ...(input.cache === 'no' ? { 'X-Cache': 'no' } : {}), + ...(input.firebase === 'no' ? { 'X-Firebase': 'no' } : {}), + }; +} + +function buildJsonPublishBody(input: JsonPublishInput): Record { + const textFields = ['title', 'click', 'icon', 'attach', 'filename', 'delay', 'email', 'call'] as const; + const texts = Object.fromEntries( + textFields + .map((key) => [key, nonEmpty(input[key])] as const) + .filter(([, v]) => v !== undefined) + ); + const message = typeof input.message === 'string' && input.message.trim().length > 0 ? input.message : undefined; + const priority = parsePriority(input.priority); + const tags = normalizeTags(input.tags); + const actions = parseActions(input.actions); + const sequenceId = nonEmpty(input.sequence_id); + if (texts['delay'] !== undefined && input.disable_cache === true) { + throw new Error('Do Not Cache cannot be combined with Delay: ntfy can only schedule cached messages.'); + } + if (texts['delay'] !== undefined && texts['email'] !== undefined) { + throw new Error('Email To cannot be combined with Delay: ntfy does not send scheduled emails.'); + } + return { + topic: validateId({ value: input.topic, label: 'Topic' }), + ...(message !== undefined ? { message } : {}), + ...texts, + ...(priority !== undefined ? { priority } : {}), + ...(tags ? { tags } : {}), + ...(actions ? { actions } : {}), + ...(input.markdown === true ? { markdown: true } : {}), + ...(sequenceId ? { sequence_id: validateId({ value: sequenceId, label: 'Sequence ID' }) } : {}), + ...(input.disable_cache === true ? { cache: 'no' } : {}), + ...(input.disable_firebase === true ? { firebase: 'no' } : {}), + }; +} + +function parseNdjson(body: unknown): NtfyMessage[] { + if (body === undefined || body === null || body === '') { + return []; + } + if (typeof body !== 'string') { + const items: unknown[] = Array.isArray(body) ? body : [body]; + return items.filter(isNtfyMessage); + } + return body + .split('\n') + .map((line, index) => ({ line: line.trim(), index })) + .filter(({ line }) => line.length > 0) + .map(({ line, index }) => { + const parsed: unknown = parseNdjsonLine({ line, index }); + if (!isNtfyMessage(parsed)) { + throw new Error(`ntfy returned an unexpected line ${index + 1}: ${line.slice(0, 200)}`); + } + return parsed; + }); +} + +function serverNowSeconds(headers: HttpHeaders | undefined): number { + const value = firstHeader({ headers, name: 'date' }); + const parsed = value ? Date.parse(value) : NaN; + return Math.floor((Number.isNaN(parsed) ? Date.now() : parsed) / 1000); +} + +function toNtfyError(error: unknown): unknown { + const response = isRecord(error) && isRecord(error['response']) ? error['response'] : undefined; + const status = response?.['status']; + if (typeof status !== 'number') { + return error; + } + const body = normalizeErrorBody(response?.['body']); + return new NtfyRequestError({ + status, + message: `ntfy request failed with HTTP ${status}: ${body}.${statusHint(status)}`, + }); +} + +async function request({ + auth, + path, + method, + headers, + body, + queryParams, + responseType, +}: NtfyRequestParams): Promise> { + const url = `${baseUrl(auth)}${path}`; + try { + return await httpClient.sendRequest({ + method, + url, + body, + queryParams, + responseType, + headers: { ...authHeaders(auth), ...headers }, + }); + } catch (error) { + throw toNtfyError(error); + } +} + +async function pollMessages({ + auth, + topics, + since, + scheduled, + filters, +}: { + auth: NtfyAuthValue; + topics: string; + since: string; + scheduled?: boolean; + filters?: PollFilters; +}): Promise<{ messages: NtfyMessage[]; truncated: boolean; serverNow: number }> { + const f = filters ?? {}; + const queryParams: QueryParams = { + poll: '1', + since, + ...(scheduled ? { sched: '1' } : {}), + ...(f.priority && f.priority.length > 0 ? { priority: f.priority.join(',') } : {}), + ...(f.tags && f.tags.length > 0 ? { tags: f.tags.join(',') } : {}), + ...(f.title ? { title: f.title } : {}), + ...(f.message ? { message: f.message } : {}), + }; + const response = await request({ + auth, + method: HttpMethod.GET, + path: `/${topics}/json`, + queryParams, + responseType: 'text', + }); + const truncatedHeader = firstHeader({ headers: response.headers, name: 'x-messages-truncated' }); + return { + messages: parseNdjson(response.body), + truncated: truncatedHeader === '1' || truncatedHeader === 'true', + serverNow: serverNowSeconds(response.headers), + }; +} + +function advanceCursor({ cursor, fetched }: { cursor: NtfyCursor; fetched: NtfyMessage[] }): { + newItems: NtfyMessage[]; + cursor: NtfyCursor; +} { + const windowFloor = cursor.lastTime - CURSOR_OVERLAP_SECONDS; + const messages = fetched.filter((m) => m.event === 'message'); + const alreadySeen = new Set(cursor.seen.map((s) => s.id)); + const newItems = uniqueById(messages) + .filter((m) => !alreadySeen.has(m.id) && m.time >= windowFloor) + .sort((a, b) => a.time - b.time); + const lastTime = messages.reduce((max, m) => Math.max(max, m.time), cursor.lastTime); + const keepFloor = lastTime - CURSOR_OVERLAP_SECONDS; + const seen = [...cursor.seen, ...newItems.map((m) => ({ id: m.id, time: m.time }))].filter( + (s) => s.time >= keepFloor + ); + return { newItems, cursor: { lastTime, seen } }; +} + +function uniqueById(messages: NtfyMessage[]): NtfyMessage[] { + const ids = new Set(); + return messages.filter((m) => { + if (ids.has(m.id)) { + return false; + } + ids.add(m.id); + return true; + }); +} + +function nonEmpty(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNtfyMessage(value: unknown): value is NtfyMessage { + return ( + isRecord(value) && + typeof value['id'] === 'string' && + typeof value['time'] === 'number' && + typeof value['event'] === 'string' + ); +} + +function parseJsonOrThrow(value: string): unknown { + try { + return JSON.parse(value); + } catch { + throw new Error( + 'Action Buttons must be a JSON array, e.g. [{"action":"view","label":"Open","url":"https://example.com"}]' + ); + } +} + +function parseNdjsonLine({ line, index }: { line: string; index: number }): unknown { + try { + return JSON.parse(line); + } catch { + throw new Error(`ntfy returned a line that is not JSON (line ${index + 1}): ${line.slice(0, 200)}`); + } +} + +function firstHeader({ headers, name }: { headers: HttpHeaders | undefined; name: string }): string | undefined { + if (!headers) { + return undefined; + } + const key = Object.keys(headers).find((k) => k.toLowerCase() === name); + const raw = key ? headers[key] : undefined; + return Array.isArray(raw) ? raw[0] : raw; +} + +function normalizeErrorBody(body: unknown): string { + const parsed = typeof body === 'string' ? tryParseJson(body) : body; + if (isRecord(parsed) && typeof parsed['error'] === 'string') { + const code = parsed['code']; + return `${parsed['error']}${typeof code === 'number' ? ` (ntfy code ${code})` : ''}`; + } + if (typeof body === 'string' && body.length > 0) { + return body.slice(0, 300); + } + return 'no details'; +} + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function statusHint(status: number): string { + if (status === 401 || status === 403) { + return ' Check the Access Token on the connection and that it may read/write this topic.'; + } + if (status === 429) { + return ' The ntfy server rate-limited this request (daily message, email or bandwidth quota). Try again later.'; + } + if (status === 413) { + return ' The message or attachment is larger than the server allows.'; + } + return ''; +} + +export class NtfyRequestError extends Error { + readonly status: number; + + constructor({ status, message }: { status: number; message: string }) { + super(message); + this.name = 'NtfyRequestError'; + this.status = status; + } +} + +export const ntfyClient = { + NTFY_ID_PATTERN, + CURSOR_OVERLAP_SECONDS, + encodeToRFC2047, + baseUrl, + authHeaders, + validateId, + validateTopicList, + parsePriority, + normalizePriorityFilter, + normalizeTags, + parseActions, + headerValue, + buildSendNotificationHeaders, + buildJsonPublishBody, + parseNdjson, + newestFirst, + serverNowSeconds, + toNtfyError, + firstHeader, + request, + pollMessages, + advanceCursor, +}; + +export type NtfyAuthValue = AppConnectionValueForAuthProperty; + +export type NtfyMessage = { + id: string; + time: number; + event: string; + topic: string; + expires?: number; + sequence_id?: string; + title?: string; + message?: string; + priority?: number; + tags?: string[]; + click?: string; + icon?: string; + actions?: Record[]; + attachment?: Record; + content_type?: string; +}; + +export type NtfyCursor = { + lastTime: number; + seen: { id: string; time: number }[]; +}; + +export type PollFilters = { + priority?: number[]; + tags?: string[]; + title?: string; + message?: string; +}; + +export type SendNotificationHeaderInput = { + message: string; + title?: string; + priority?: string; + tags?: unknown; + icon?: string; + actions?: string; + click?: string; + delay?: string; + attach?: string; + filename?: string; + markdown?: boolean; + email?: string; + call?: string; + sequence_id?: string; + cache?: string; + firebase?: string; +}; + +export type JsonPublishInput = { + topic: unknown; + message?: unknown; + title?: unknown; + priority?: unknown; + tags?: unknown; + click?: unknown; + icon?: unknown; + attach?: unknown; + filename?: unknown; + markdown?: unknown; + actions?: unknown; + delay?: unknown; + email?: unknown; + call?: unknown; + sequence_id?: unknown; + disable_cache?: unknown; + disable_firebase?: unknown; +}; + +type NtfyRequestParams = { + auth: NtfyAuthValue; + path: string; + method: HttpMethod; + headers?: HttpHeaders; + body?: HttpRequest['body']; + queryParams?: QueryParams; + responseType?: HttpRequest['responseType']; +}; diff --git a/packages/pieces/community/ntfy/src/lib/common/props.ts b/packages/pieces/community/ntfy/src/lib/common/props.ts new file mode 100644 index 000000000000..d056e5e83f0a --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/common/props.ts @@ -0,0 +1,129 @@ +import { Property } from '@activepieces/pieces-framework'; + +export const PRIORITY_OPTIONS = [ + { label: '1 - Min', value: 1 }, + { label: '2 - Low', value: 2 }, + { label: '3 - Default', value: 3 }, + { label: '4 - High', value: 4 }, + { label: '5 - Max (urgent)', value: 5 }, +]; + +export const ntfyProps = { + topic: () => + Property.ShortText({ + displayName: 'Topic', + description: + 'The topic name, e.g. backups_home. 1-64 characters: letters, digits, "-" and "_". Anyone who knows a public topic name can read it, so pick a hard-to-guess name.', + required: true, + }), + topics: () => + Property.ShortText({ + displayName: 'Topics', + description: + 'One topic, or several separated by commas, e.g. alerts,backups_home. Letters, digits, "-" and "_" only.', + required: true, + }), + sequenceId: (description: string) => + Property.ShortText({ + displayName: 'Sequence ID', + description, + required: true, + }), + requiredMessage: (description: string) => + Property.LongText({ + displayName: 'Message', + description, + required: true, + }), + optionalMessage: (description: string) => + Property.LongText({ + displayName: 'Message', + description, + required: false, + }), + title: () => + Property.ShortText({ + displayName: 'Title', + description: 'Optional notification title, e.g. "Backup finished". Defaults to the topic URL in the apps.', + required: false, + }), + priority: () => + Property.StaticDropdown({ + displayName: 'Priority', + description: 'How urgently the phone should alert. Leave empty for the default (3).', + required: false, + options: { options: PRIORITY_OPTIONS }, + }), + tags: () => + Property.Array({ + displayName: 'Tags', + description: + 'Tags shown with the notification. Tags that match an emoji short code (e.g. warning, white_check_mark, rotating_light) are shown as that emoji.', + required: false, + }), + click: () => + Property.ShortText({ + displayName: 'Click URL', + description: 'Web page or app link opened when the notification is tapped, e.g. https://example.com/orders/42', + required: false, + }), + icon: () => + Property.ShortText({ + displayName: 'Icon URL', + description: 'URL of a JPEG or PNG image used as the notification icon, e.g. https://example.com/icon.png', + required: false, + }), + attach: () => + Property.ShortText({ + displayName: 'Attachment URL', + description: 'Public URL of a file to attach, e.g. https://example.com/report.pdf. ntfy does not download it; the apps link to it.', + required: false, + }), + filename: () => + Property.ShortText({ + displayName: 'Attachment File Name', + description: 'Name shown for the attachment, e.g. report.pdf. Only used together with an attachment.', + required: false, + }), + markdown: () => + Property.Checkbox({ + displayName: 'Markdown', + description: 'Render the message as Markdown (supported in the web app and on Android).', + required: false, + defaultValue: false, + }), + actions: () => + Property.Json({ + displayName: 'Action Buttons', + description: + 'Up to 3 buttons as a JSON array, e.g. [{"action":"view","label":"Open","url":"https://example.com"}]. Types: view, http, broadcast, copy. See https://docs.ntfy.sh/publish/#action-buttons', + required: false, + }), + delay: () => + Property.ShortText({ + displayName: 'Delay', + description: + "Schedule delivery for later: a duration (30m, 2h, 1 day), a Unix timestamp or natural language like 'tomorrow, 10am'. Between 10 seconds and 3 days on default servers.", + required: false, + }), + email: () => + Property.ShortText({ + displayName: 'Email To', + description: 'Also send the notification to this email address, e.g. jane@example.com. ntfy.sh allows 5 emails a day without an account; with an access token, ntfy.sh only sends to addresses verified in the account settings.', + required: false, + }), + call: () => + Property.ShortText({ + displayName: 'Phone Call To', + description: + 'Also read the message out in a phone call to this number, e.g. +12223334444, or "yes" for your first verified number. Needs a paid ntfy.sh plan (or a self-hosted server with Twilio) and a verified number.', + required: false, + }), + publishSequenceId: () => + Property.ShortText({ + displayName: 'Sequence ID', + description: + 'Your own ID for this notification, e.g. backup-job-42, so later steps can update, clear or delete it. Letters, digits, "-" and "_". Needs ntfy server v2.16.0+.', + required: false, + }), +}; diff --git a/packages/pieces/community/ntfy/src/lib/output-schemas.ts b/packages/pieces/community/ntfy/src/lib/output-schemas.ts index d001aac22728..edb6de8f7419 100644 --- a/packages/pieces/community/ntfy/src/lib/output-schemas.ts +++ b/packages/pieces/community/ntfy/src/lib/output-schemas.ts @@ -1,89 +1,288 @@ -import { OutputSchema } from '@activepieces/pieces-framework'; +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; -export const sendNotificationActionOutputSchema: OutputSchema = { - fields: [ +function messageFields({ + prefix = '', + uploads = true, + omit = [], +}: { + prefix?: string; + uploads?: boolean; + omit?: string[]; +} = {}): OutputSchemaField[] { + const at = (key: string) => (prefix ? { value: `${prefix}${key}` } : {}); + const attachmentChildren: OutputSchemaField[] = [ + { key: 'name', label: 'File Name' }, + { key: 'url', label: 'URL', format: 'url' }, + ]; + if (uploads) { + attachmentChildren.push( + { key: 'type', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { + key: 'expires', + label: 'Expires', + description: 'Unix timestamp in seconds when the uploaded file is deleted (3 hours after upload by default).', + } + ); + } + const fields: OutputSchemaField[] = [ { key: 'id', label: 'Message ID', - value: 'body.id', + ...at('id'), description: 'Identifier ntfy assigned to the published message.', }, { key: 'topic', label: 'Topic', - value: 'body.topic', + ...at('topic'), description: 'The topic the message was published to.', }, { key: 'title', label: 'Title', - value: 'body.title', + ...at('title'), }, { key: 'message', label: 'Message', - value: 'body.message', + ...at('message'), }, { key: 'time', label: 'Delivery Time', - value: 'body.time', + ...at('time'), description: 'Unix timestamp in seconds for when ntfy delivers the message. Equal to the send time unless Delay was set, in which case it is the scheduled delivery time.', }, { key: 'expires', label: 'Expires', - value: 'body.expires', + ...at('expires'), description: 'Unix timestamp in seconds for when ntfy stops caching the message, always 12 hours after Delivery Time.', }, { key: 'event', label: 'Event', - value: 'body.event', + ...at('event'), description: 'The ntfy event type, "message" for a published notification.', }, { key: 'priority', label: 'Priority', - value: 'body.priority', + ...at('priority'), format: 'number', description: '1 (lowest) to 5 (highest). Omitted by ntfy when the default priority 3 was used.', }, { key: 'tags', label: 'Tags', - value: 'body.tags', + ...at('tags'), description: 'Only present when tags were sent.', }, { key: 'click', label: 'Click URL', - value: 'body.click', + ...at('click'), format: 'url', description: 'Opened when the notification is clicked. Only present when a click URL was sent.', }, { key: 'icon', label: 'Icon', - value: 'body.icon', + ...at('icon'), format: 'image', description: 'Only present when an icon URL was sent.', }, { key: 'actions', label: 'Action Buttons', - value: 'body.actions', + ...at('actions'), labelKey: 'label', description: 'Only present when action buttons were sent.', listItems: [ { key: 'id', label: 'Action ID' }, - { key: 'action', label: 'Type', description: 'One of "view", "http" or "broadcast".' }, + { key: 'action', label: 'Type', description: 'One of "view", "http", "broadcast" or "copy".' }, { key: 'label', label: 'Label' }, { key: 'url', label: 'URL', format: 'url' }, { key: 'clear', label: 'Clear Notification', format: 'boolean' }, ], }, + { + key: 'sequence_id', + label: 'Sequence ID', + ...at('sequence_id'), + description: + 'Only present when a Sequence ID was set. Use it (or the Message ID) to update, clear or delete this notification.', + }, + { + key: 'content_type', + label: 'Content Type', + ...at('content_type'), + description: '"text/markdown" when the message was sent as Markdown, otherwise absent.', + }, + { + key: 'attachment', + label: 'Attachment', + ...at('attachment'), + description: uploads + ? 'Only present when a file or attachment URL was sent. Type, size and expiry are only set for files uploaded to the ntfy server.' + : 'Only present when an attachment URL was sent.', + children: attachmentChildren, + }, + ]; + return fields.filter((field) => !omit.includes(field.key)); +} + +export const sendNotificationActionOutputSchema: OutputSchema = { + fields: messageFields({ prefix: 'body.', uploads: false }), +}; + +export const publishedMessageOutputSchema: OutputSchema = { + fields: messageFields({ uploads: false }), +}; + +export const sentFileOutputSchema: OutputSchema = { + fields: messageFields({ omit: ['icon', 'actions', 'sequence_id', 'content_type'] }), +}; + +export const sequenceEventOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Event ID', description: 'ID of the clear/delete event ntfy appended to the topic.' }, + { key: 'sequence_id', label: 'Sequence ID', description: 'The notification the event applies to.' }, + { key: 'topic', label: 'Topic' }, + { + key: 'event', + label: 'Event', + description: '"message_clear" (marked read and dismissed) or "message_delete" (removed, or scheduled message cancelled).', + }, + { key: 'time', label: 'Time', description: 'Unix timestamp in seconds when the event was published.' }, + { key: 'expires', label: 'Expires', description: 'Unix timestamp in seconds for when ntfy stops caching the event.' }, + ], +}; + +const messageListFields = (description: string): OutputSchemaField[] => [ + { + key: 'messages', + label: 'Messages', + labelKey: 'message', + description, + listItems: messageFields(), + }, + { key: 'count', label: 'Count', format: 'number', description: 'Number of messages returned.' }, +]; + +const serverTruncatedField: OutputSchemaField = { + key: 'server_truncated', + label: 'Server Truncated', + format: 'boolean', + description: 'True when the ntfy server capped the replay (X-Messages-Truncated), so some messages were not returned.', +}; + +export const fetchMessagesOutputSchema: OutputSchema = { + fields: [ + ...messageListFields('Cached messages, newest first.'), + { + key: 'total_matched', + label: 'Total Matched', + format: 'number', + description: 'Messages that matched before Limit was applied.', + }, + { + key: 'has_more', + label: 'Has More', + format: 'boolean', + description: 'True when Limit cut off older matching messages.', + }, + serverTruncatedField, + ], +}; + +export const listScheduledMessagesOutputSchema: OutputSchema = { + fields: [ + ...messageListFields('Messages scheduled for later delivery that have not been delivered yet, soonest first.'), + serverTruncatedField, + ], +}; + +export const accountOutputSchema: OutputSchema = { + fields: [ + { key: 'username', label: 'Username', description: '"*" for anonymous access (no token or no account).' }, + { key: 'role', label: 'Role', description: 'anonymous, user or admin.' }, + { key: 'tier_code', label: 'Tier Code' }, + { key: 'tier_name', label: 'Tier Name' }, + { key: 'limits_basis', label: 'Limits Based On', description: '"ip" (per IP address) or "tier".' }, + { key: 'limits_messages', label: 'Daily Message Limit', format: 'number' }, + { + key: 'limits_messages_expiry_duration', + label: 'Message Cache Duration', + format: 'number', + description: 'Seconds.', + }, + { key: 'limits_emails', label: 'Daily Email Limit', format: 'number' }, + { key: 'limits_calls', label: 'Daily Call Limit', format: 'number' }, + { key: 'limits_reservations', label: 'Reserved Topic Limit', format: 'number' }, + { key: 'limits_attachment_file_size', label: 'Max Attachment Size', format: 'filesize' }, + { key: 'limits_attachment_total_size', label: 'Attachment Storage Limit', format: 'filesize' }, + { + key: 'limits_attachment_expiry_duration', + label: 'Attachment Expiry', + format: 'number', + description: 'Seconds.', + }, + { key: 'limits_attachment_bandwidth', label: 'Daily Attachment Bandwidth', format: 'filesize' }, + { key: 'stats_messages', label: 'Messages Sent Today', format: 'number' }, + { key: 'stats_messages_remaining', label: 'Messages Remaining Today', format: 'number' }, + { key: 'stats_emails', label: 'Emails Sent Today', format: 'number' }, + { key: 'stats_emails_remaining', label: 'Emails Remaining Today', format: 'number' }, + { key: 'stats_calls', label: 'Calls Made Today', format: 'number' }, + { key: 'stats_calls_remaining', label: 'Calls Remaining Today', format: 'number' }, + { key: 'stats_reservations', label: 'Reserved Topics', format: 'number' }, + { key: 'stats_reservations_remaining', label: 'Reserved Topics Remaining', format: 'number' }, + { key: 'stats_attachment_total_size', label: 'Attachment Storage Used', format: 'filesize' }, + { key: 'stats_attachment_total_size_remaining', label: 'Attachment Storage Remaining', format: 'filesize' }, + { + key: 'reserved_topics', + label: 'Reserved Topics', + description: 'Comma-separated names of topics reserved by this account, empty when none.', + }, + ], +}; + +export const serverStatsOutputSchema: OutputSchema = { + fields: [ + { key: 'messages', label: 'Messages Published', format: 'number', description: 'Total messages since the server started counting.' }, + { key: 'messages_rate', label: 'Messages per Second', format: 'number', description: 'Recent average publish rate.' }, + ], +}; + +export const serverHealthOutputSchema: OutputSchema = { + fields: [ + { key: 'healthy', label: 'Healthy', format: 'boolean' }, + { key: 'server_url', label: 'Server URL', format: 'url' }, + ], +}; + +export const attachmentInfoOutputSchema: OutputSchema = { + fields: [ + { key: 'message_id', label: 'Message ID' }, + { key: 'exists', label: 'Exists', format: 'boolean', description: 'False when the file was never uploaded or has expired.' }, + { key: 'size_bytes', label: 'Size', format: 'filesize' }, + { key: 'content_type', label: 'Content Type', description: 'Null when the server does not report it.' }, + { key: 'url', label: 'Download URL', format: 'url' }, + ], +}; + +export const newMessageTriggerOutputSchema: OutputSchema = { + fields: [ + ...messageFields(), + { + key: 'replay_truncated', + label: 'Replay Truncated', + format: 'boolean', + description: + 'True when the ntfy server capped this poll on at least one of the topics (more than 10 MB of messages), so messages published since the previous poll may have been skipped. ntfy keeps only the newest 10 MB per topic and offers no way to fetch the rest.', + }, ], }; diff --git a/packages/pieces/community/ntfy/src/lib/triggers/new-message.ts b/packages/pieces/community/ntfy/src/lib/triggers/new-message.ts new file mode 100644 index 000000000000..f810a39e0270 --- /dev/null +++ b/packages/pieces/community/ntfy/src/lib/triggers/new-message.ts @@ -0,0 +1,127 @@ +import { createTrigger, Property, TriggerStrategy } from '@activepieces/pieces-framework'; +import { ntfyAuth } from '../auth'; +import { ntfyClient, NtfyAuthValue, NtfyCursor, PollFilters } from '../common/client'; +import { ntfyProps, PRIORITY_OPTIONS } from '../common/props'; +import { newMessageTriggerOutputSchema } from '../output-schemas'; + +const CURSOR_KEY = 'ntfy_new_message_cursor'; + +export const newMessage = createTrigger({ + auth: ntfyAuth, + name: 'new_message', + classification: 'READ', + displayName: 'New Message', + description: + 'Triggers when a message is published to a ntfy topic. Needs the server to cache messages (ntfy.sh keeps them 12 hours); messages sent with "Do not cache" are never seen.', + aiMetadata: { + description: + 'Fires once per new message published to the chosen ntfy topic(s), including scheduled messages when they are delivered and updates sent with a sequence ID; optionally only for given priorities or tags. Clear and delete events do not fire it. Polls the server cache, so uncached messages are missed. If more than 10 MB of messages arrive on any of the topics between two polls, the server returns only the newest ones and cannot return the rest; every run from that poll carries replay_truncated: true.', + }, + props: { + topics: ntfyProps.topics(), + priority: Property.StaticMultiSelectDropdown({ + displayName: 'Priority Filter', + description: 'Only trigger for messages with any of these priorities. Leave empty for all.', + required: false, + options: { options: PRIORITY_OPTIONS }, + }), + tags: Property.Array({ + displayName: 'Tags Filter', + description: 'Only trigger for messages that have all of these tags, e.g. backup.', + required: false, + }), + }, + outputSchema: newMessageTriggerOutputSchema, + sampleData: { + id: 'GjpkhqcNaNyE', + time: 1790662726, + expires: 1790705926, + event: 'message', + topic: 'backups_home', + title: 'Backup finished', + message: 'Nightly backup finished in 4m 12s', + priority: 4, + tags: ['white_check_mark', 'backup'], + click: 'https://example.com/backups/42', + replay_truncated: false, + }, + type: TriggerStrategy.POLLING, + async onEnable(context) { + if (context.isRepublish && (await context.store.get(CURSOR_KEY))) { + return; + } + const cursor = await initialCursor({ auth: context.auth, propsValue: context.propsValue }); + await context.store.put(CURSOR_KEY, cursor); + }, + async onDisable(context) { + await context.store.delete(CURSOR_KEY); + }, + async test(context) { + const topics = ntfyClient.validateTopicList(context.propsValue.topics); + const result = await ntfyClient.pollMessages({ + auth: context.auth, + topics, + since: '12h', + filters: filtersFrom(context.propsValue), + }); + return ntfyClient + .newestFirst(result.messages.filter((m) => m.event === 'message')) + .slice(0, 5) + .map((message) => ({ ...message, replay_truncated: result.truncated })); + }, + async run(context) { + const stored = await context.store.get(CURSOR_KEY); + if (!stored) { + await context.store.put( + CURSOR_KEY, + await initialCursor({ auth: context.auth, propsValue: context.propsValue }) + ); + return []; + } + const topics = ntfyClient.validateTopicList(context.propsValue.topics); + const since = Math.max(0, stored.lastTime - ntfyClient.CURSOR_OVERLAP_SECONDS); + const result = await ntfyClient.pollMessages({ + auth: context.auth, + topics, + since: String(since), + filters: filtersFrom(context.propsValue), + }); + const { newItems, cursor } = ntfyClient.advanceCursor({ cursor: stored, fetched: result.messages }); + await context.store.put(CURSOR_KEY, cursor); + return newItems.map((message) => ({ ...message, replay_truncated: result.truncated })); + }, +}); + +async function initialCursor({ + auth, + propsValue, +}: { + auth: NtfyAuthValue; + propsValue: TriggerPropsValue; +}): Promise { + const topics = ntfyClient.validateTopicList(propsValue.topics); + const result = await ntfyClient.pollMessages({ + auth, + topics, + since: `${ntfyClient.CURSOR_OVERLAP_SECONDS}s`, + filters: filtersFrom(propsValue), + }); + const seeded = ntfyClient.advanceCursor({ + cursor: { lastTime: result.serverNow, seen: [] }, + fetched: result.messages, + }); + return seeded.cursor; +} + +function filtersFrom(propsValue: TriggerPropsValue): PollFilters { + return { + priority: ntfyClient.normalizePriorityFilter(propsValue.priority), + tags: ntfyClient.normalizeTags(propsValue.tags), + }; +} + +type TriggerPropsValue = { + topics?: string; + priority?: unknown[]; + tags?: unknown[]; +}; diff --git a/packages/pieces/community/ntfy/test/actions.test.ts b/packages/pieces/community/ntfy/test/actions.test.ts new file mode 100644 index 000000000000..5e2a14323935 --- /dev/null +++ b/packages/pieces/community/ntfy/test/actions.test.ts @@ -0,0 +1,399 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sendNotification } from '../src/lib/actions/send-notification'; +import { publishMessage } from '../src/lib/actions/publish-message'; +import { deleteNotification } from '../src/lib/actions/delete-notification'; +import { clearNotification } from '../src/lib/actions/clear-notification'; +import { updateNotification } from '../src/lib/actions/update-notification'; +import { fetchMessages } from '../src/lib/actions/fetch-messages'; +import { listScheduledMessages } from '../src/lib/actions/list-scheduled-messages'; +import { getAccount } from '../src/lib/actions/get-account'; +import { getAttachmentInfo } from '../src/lib/actions/get-attachment-info'; +import { sendFile } from '../src/lib/actions/send-file'; +import { newMessage } from '../src/lib/triggers/new-message'; +import { ntfy } from '../src/index'; +import { ntfyClient } from '../src/lib/common/client'; +import { ndjson, runAction, testAuth, triggerContext } 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), + }, + }; +}); + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('Send Notification', () => { + it('publishes with an empty title (0.3.0 crashed here) and no X-Title header', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'x' } }); + const result = await runAction({ + action: sendNotification, + propsValue: { topic: 'alerts', message: 'hello', title: undefined }, + }); + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe(HttpMethod.POST); + expect(request.url).toBe('https://ntfy.example.com/alerts'); + expect(request.headers).not.toHaveProperty('X-Title'); + expect(request.authentication).toBeUndefined(); + expect(result).toEqual({ status: 200, headers: {}, body: { id: 'x' } }); + }); +}); + +describe('Publish Message (JSON)', () => { + it('posts JSON to the root URL with the bearer token', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'm1', event: 'message' } }); + const result = await runAction({ + action: publishMessage, + token: 'tk_abc', + propsValue: { topic: 'alerts', message: 'hi', priority: 5, tags: ['warning'], markdown: false }, + }); + const request = sendRequest.mock.calls[0][0]; + expect(request.url).toBe('https://ntfy.example.com/'); + expect(request.headers).toMatchObject({ Authorization: 'Bearer tk_abc', 'Content-Type': 'application/json' }); + expect(request.body).toEqual({ topic: 'alerts', message: 'hi', priority: 5, tags: ['warning'] }); + expect(result).toEqual({ id: 'm1', event: 'message' }); + }); +}); + +describe('Delete Notification', () => { + it('sends DELETE // and no Authorization header without a token', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'e', event: 'message_delete' } }); + await runAction({ action: deleteNotification, propsValue: { topic: 'alerts', sequence_id: 'job-1' } }); + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe(HttpMethod.DELETE); + expect(request.url).toBe('https://ntfy.example.com/alerts/job-1'); + expect(request.headers).not.toHaveProperty('Authorization'); + }); +}); + +describe('Fetch Messages', () => { + it('parses NDJSON, filters events, sorts newest first and reports limit cut-off', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'x-messages-truncated': '1' }, + body: ndjson([ + { id: 'a', time: 1, event: 'message', topic: 't' }, + { id: 'b', time: 3, event: 'message', topic: 't' }, + { id: 'c', time: 2, event: 'message_clear', topic: 't' }, + { id: 'd', time: 2, event: 'message', topic: 't' }, + ]), + }); + const result = await runAction({ + action: fetchMessages, + propsValue: { topics: 't', since: undefined, priority: [4, 5], tags: ['x'], limit: 2 }, + }); + const request = sendRequest.mock.calls[0][0]; + expect(request.url).toBe('https://ntfy.example.com/t/json'); + expect(request.responseType).toBe('text'); + expect(request.queryParams).toEqual({ poll: '1', since: '12h', priority: '4,5', tags: 'x' }); + expect(result).toEqual({ + messages: [ + { id: 'b', time: 3, event: 'message', topic: 't' }, + { id: 'd', time: 2, event: 'message', topic: 't' }, + ], + count: 2, + total_matched: 3, + has_more: true, + server_truncated: true, + }); + }); +}); + +describe('List Scheduled Messages', () => { + it('keeps only messages due after the server time', async () => { + const now = 1790662800; + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { date: new Date(now * 1000).toUTCString() }, + body: ndjson([ + { id: 'past', time: now - 10, event: 'message', topic: 't' }, + { id: 'later', time: now + 7200, event: 'message', topic: 't' }, + { id: 'soon', time: now + 60, event: 'message', topic: 't' }, + ]), + }); + const result = await runAction({ action: listScheduledMessages, propsValue: { topics: 't' } }); + expect(sendRequest.mock.calls[0][0].queryParams).toMatchObject({ poll: '1', sched: '1', since: 'all' }); + expect(result).toMatchObject({ count: 2, server_truncated: false }); + expect(JSON.stringify(result)).toMatch(/"soon".*"later"/); + }); + + it('reports when the server capped the replay', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: { 'x-messages-truncated': '1' }, body: '' }); + const result = await runAction({ action: listScheduledMessages, propsValue: { topics: 't' } }); + expect(result).toMatchObject({ count: 0, server_truncated: true }); + }); +}); + +describe('Update Notification', () => { + it('posts JSON to the root URL with the sequence id and only the fields that are set', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'u1', sequence_id: 'job-1' } }); + const result = await runAction({ + action: updateNotification, + propsValue: { topic: 'alerts', sequence_id: ' job-1 ', message: '50% done', title: '', priority: 3 }, + }); + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe(HttpMethod.POST); + expect(request.url).toBe('https://ntfy.example.com/'); + expect(request.body).toEqual({ topic: 'alerts', message: '50% done', priority: 3, sequence_id: 'job-1' }); + expect(result).toEqual({ id: 'u1', sequence_id: 'job-1' }); + }); + + it('refuses an invalid sequence id before any request', async () => { + await expect( + runAction({ action: updateNotification, propsValue: { topic: 'alerts', sequence_id: 'a/b', message: 'x' } }) + ).rejects.toThrow(/Sequence ID/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('Clear Notification', () => { + it('sends PUT ///clear', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'c', event: 'message_clear' } }); + await runAction({ action: clearNotification, propsValue: { topic: 'alerts', sequence_id: 'job-1' } }); + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe(HttpMethod.PUT); + expect(request.url).toBe('https://ntfy.example.com/alerts/job-1/clear'); + }); +}); + +describe('Get Account', () => { + it('never returns tokens, emails, phone numbers or the sync topic', async () => { + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: { + username: 'jane', + role: 'user', + sync_topic: 'secret-sync', + tokens: [{ token: 'tk_secret', label: 'x' }], + emails: [{ address: 'jane@example.com', primary: true }], + phone_numbers: ['+12223334444'], + tier: { code: 'free', name: 'Free' }, + limits: { basis: 'tier', messages: 250 }, + stats: { messages: 8, messages_remaining: 242 }, + reservations: [{ topic: 'mine', everyone: 'deny-all' }], + }, + }); + const result = await runAction({ action: getAccount, propsValue: {}, token: 'tk_secret' }); + const text = JSON.stringify(result); + expect(text).not.toMatch(/tk_secret|jane@example.com|2223334444|secret-sync/); + expect(result).toMatchObject({ + username: 'jane', + tier_name: 'Free', + limits_messages: 250, + stats_messages_remaining: 242, + reserved_topics: 'mine', + }); + }); +}); + +describe('Get Attachment Info', () => { + it('returns exists:false on 404 and strips a file extension', async () => { + sendRequest.mockRejectedValueOnce({ response: { status: 404, body: { code: 40401, error: 'page not found' } } }); + const result = await runAction({ action: getAttachmentInfo, propsValue: { message_id: 'NOPEnope1234.txt' } }); + expect(sendRequest.mock.calls[0][0]).toMatchObject({ + method: HttpMethod.HEAD, + url: 'https://ntfy.example.com/file/NOPEnope1234', + }); + expect(result).toEqual({ + message_id: 'NOPEnope1234', + exists: false, + size_bytes: null, + content_type: null, + url: 'https://ntfy.example.com/file/NOPEnope1234', + }); + }); + + it('rethrows other errors', async () => { + sendRequest.mockRejectedValueOnce({ response: { status: 401, body: { error: 'unauthorized' } } }); + await expect(runAction({ action: getAttachmentInfo, propsValue: { message_id: 'abc' } })).rejects.toThrow( + /HTTP 401/ + ); + }); +}); + +describe('Send File', () => { + it('uploads the file bytes with a duck-typed file object', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'f1' } }); + await runAction({ + action: sendFile, + propsValue: { + topic: 'alerts', + file: { filename: 'note.txt', data: Buffer.from('hello'), extension: 'txt', base64: 'aGVsbG8=' }, + message: undefined, + priority: 4, + }, + }); + const request = sendRequest.mock.calls[0][0]; + expect(request.method).toBe(HttpMethod.PUT); + expect(request.body.toString()).toBe('hello'); + expect(request.headers).toMatchObject({ 'X-Priority': '4' }); + expect(request.headers).not.toHaveProperty('X-Message'); + }); + + it('encodes a non-ASCII tag so the request can be sent', async () => { + sendRequest.mockResolvedValueOnce({ status: 200, headers: {}, body: { id: 'f2' } }); + await runAction({ + action: sendFile, + propsValue: { topic: 'alerts', file: { filename: 'a.txt', data: Buffer.from('a') }, tags: ['✅', 'ok'] }, + }); + const tags = sendRequest.mock.calls[0][0].headers['X-Tags']; + expect(tags).toBe(ntfyClient.encodeToRFC2047('✅,ok')); + expect(() => new Headers({ 'X-Tags': tags })).not.toThrow(); + }); +}); + +describe('New Message trigger', () => { + it('seeds the cursor on enable, then emits each new message once', async () => { + const store = new Map(); + const ctx = triggerContext({ propsValue: { topics: 't', priority: undefined, tags: undefined }, store }); + const now = 1790662800; + const dateHeader = { date: new Date(now * 1000).toUTCString() }; + + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: dateHeader, + body: ndjson([{ id: 'before', time: now - 5, event: 'message', topic: 't' }]), + }); + await newMessage.onEnable(ctx); + expect(sendRequest.mock.calls[0][0].queryParams).toMatchObject({ since: '60s' }); + + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: dateHeader, + body: ndjson([ + { id: 'before', time: now - 5, event: 'message', topic: 't' }, + { id: 'n1', time: now + 10, event: 'message', topic: 't' }, + { id: 'n2', time: now + 11, event: 'message', topic: 't' }, + ]), + }); + const first = await newMessage.run(ctx); + expect(sendRequest.mock.calls[1][0].queryParams).toMatchObject({ since: String(now - 60) }); + expect(first).toEqual([expect.objectContaining({ id: 'n1' }), expect.objectContaining({ id: 'n2' })]); + + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: dateHeader, + body: ndjson([ + { id: 'n1', time: now + 10, event: 'message', topic: 't' }, + { id: 'n2', time: now + 11, event: 'message', topic: 't' }, + ]), + }); + expect(await newMessage.run(ctx)).toEqual([]); + }); + + it('marks the runs of a capped replay that may have skipped messages and still advances the cursor', async () => { + const store = new Map([['ntfy_new_message_cursor', JSON.stringify({ lastTime: 1000, seen: [] })]]); + const ctx = triggerContext({ propsValue: { topics: 't', priority: undefined, tags: undefined }, store }); + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'x-messages-truncated': '1' }, + body: ndjson([ + { id: 'k1', time: 1500, event: 'message', topic: 't' }, + { id: 'k2', time: 1501, event: 'message', topic: 't' }, + ]), + }); + const truncated = await newMessage.run(ctx); + expect(truncated).toEqual([ + expect.objectContaining({ id: 'k1', replay_truncated: true }), + expect.objectContaining({ id: 'k2', replay_truncated: true }), + ]); + expect(JSON.parse(store.get('ntfy_new_message_cursor') ?? 'null')).toMatchObject({ lastTime: 1501 }); + + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: ndjson([ + { id: 'k2', time: 1501, event: 'message', topic: 't' }, + { id: 'k3', time: 1502, event: 'message', topic: 't' }, + ]), + }); + expect(await newMessage.run(ctx)).toEqual([expect.objectContaining({ id: 'k3', replay_truncated: false })]); + }); + + it('flags every run of a multi-topic poll when one topic was capped, even if another topic returns older messages', async () => { + const store = new Map([ + ['ntfy_new_message_cursor', JSON.stringify({ lastTime: 1000, seen: [{ id: 'b-old', time: 990 }] })], + ]); + const ctx = triggerContext({ propsValue: { topics: 'ta,tb', priority: undefined, tags: undefined }, store }); + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'x-messages-truncated': '1' }, + body: ndjson([ + { id: 'b-old', time: 990, event: 'message', topic: 'tb' }, + { id: 'a-kept', time: 1400, event: 'message', topic: 'ta' }, + { id: 'b-new', time: 1401, event: 'message', topic: 'tb' }, + ]), + }); + const runs = await newMessage.run(ctx); + expect(sendRequest.mock.calls[0][0].url).toBe('https://ntfy.example.com/ta,tb/json'); + expect(runs).toEqual([ + expect.objectContaining({ id: 'a-kept', replay_truncated: true }), + expect.objectContaining({ id: 'b-new', replay_truncated: true }), + ]); + }); + + it('flags a capped poll even when the only messages returned are older than the cursor', async () => { + const store = new Map([['ntfy_new_message_cursor', JSON.stringify({ lastTime: 1000, seen: [] })]]); + const ctx = triggerContext({ propsValue: { topics: 't', priority: [5], tags: undefined }, store }); + sendRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'x-messages-truncated': '1' }, + body: ndjson([{ id: 'late', time: 995, event: 'message', topic: 't', priority: 5 }]), + }); + expect(await newMessage.run(ctx)).toEqual([expect.objectContaining({ id: 'late', replay_truncated: true })]); + }); + + it('keeps the cursor on republish', async () => { + const store = new Map([['ntfy_new_message_cursor', JSON.stringify({ lastTime: 5, seen: [] })]]); + const ctx = { ...triggerContext({ propsValue: { topics: 't' }, store }), isRepublish: true }; + await newMessage.onEnable(ctx); + expect(sendRequest).not.toHaveBeenCalled(); + expect(JSON.parse(store.get('ntfy_new_message_cursor') ?? 'null')).toEqual({ lastTime: 5, seen: [] }); + }); +}); + +describe('Custom API Call base URL', () => { + const propsValue = { url: { url: '/v1/health' }, method: HttpMethod.GET, headers: {}, queryParams: {}, body: {} }; + + it('uses the validated Server URL, so a relative path cannot land in a query string', async () => { + const customApiCall = ntfy.getAction('custom_api_call'); + if (!customApiCall) { + throw new Error('custom_api_call is missing'); + } + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response('{"healthy":true}', { status: 200, headers: { 'content-type': 'application/json' } })); + try { + await runAction({ action: customApiCall, propsValue }); + const transport = { mockedHttpClientCalls: sendRequest.mock.calls.length, fetchCalls: fetchSpy.mock.calls.length }; + expect(transport).toEqual({ mockedHttpClientCalls: 0, fetchCalls: 1 }); + expect(String(fetchSpy.mock.calls[0][0])).toBe('https://ntfy.example.com/v1/health'); + fetchSpy.mockClear(); + await expect( + runAction({ action: customApiCall, propsValue, baseUrl: 'https://ntfy.example.com/?t=1' }) + ).rejects.toThrow(/"\?" or "#"/); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendRequest).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); +}); + +describe('Custom API Call auth mapping', () => { + it('sends no Authorization header without a token (0.3.0 sent "Bearer undefined") and Bearer with one', () => { + expect(ntfy.getAction('custom_api_call')).toBeDefined(); + expect(ntfyClient.authHeaders(testAuth())).toEqual({}); + expect(ntfyClient.authHeaders(testAuth({ token: ' ' }))).toEqual({}); + expect(ntfyClient.authHeaders(testAuth({ token: 'tk_abc' }))).toEqual({ Authorization: 'Bearer tk_abc' }); + }); +}); diff --git a/packages/pieces/community/ntfy/test/client.test.ts b/packages/pieces/community/ntfy/test/client.test.ts new file mode 100644 index 000000000000..341fe12eb35a --- /dev/null +++ b/packages/pieces/community/ntfy/test/client.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; +import { AppConnectionType } from '@activepieces/pieces-framework'; +import { ntfyClient, NtfyAuthValue, NtfyMessage } from '../src/lib/common/client'; + +const decode = (v: unknown) => { + const m = /^=\?UTF-8\?B\?(.*)\?=$/.exec(String(v)); + return m ? Buffer.from(m[1], 'base64').toString('utf-8') : undefined; +}; + +function legacyHeaders(p: { + title: string; + message: string; + priority?: string; + tags?: string[]; + icon?: string; + actions?: string; + click?: string; + delay?: string; +}) { + return { + 'X-Message': ntfyClient.encodeToRFC2047(p.message), + 'X-Title': ntfyClient.encodeToRFC2047(p.title), + 'X-Priority': p.priority, + 'X-Tags': p.tags?.join(','), + 'X-Icon': p.icon, + 'X-Actions': p.actions, + 'X-Click': p.click, + 'X-Delay': p.delay, + }; +} + +describe('buildSendNotificationHeaders (Send Notification)', () => { + it('does not crash and sends no X-Title when the title is empty', () => { + expect(() => ntfyClient.buildSendNotificationHeaders({ message: 'hi', title: undefined })).not.toThrow(); + const headers = ntfyClient.buildSendNotificationHeaders({ message: 'hi', title: undefined }); + expect(headers).not.toHaveProperty('X-Title'); + expect(ntfyClient.buildSendNotificationHeaders({ message: 'hi', title: '' })).not.toHaveProperty('X-Title'); + expect(decode(headers['X-Message'])).toBe('hi'); + }); + + it('builds the same headers as 0.3.0 when a title is set and no new props are used', () => { + const input = { + title: 'Ünïcode title', + message: 'multi\nline', + priority: 'high', + tags: ['a', 'b'], + icon: 'https://example.com/i.png', + actions: 'view, Open, https://example.com', + click: 'https://example.com', + delay: '30m', + }; + expect(ntfyClient.buildSendNotificationHeaders(input)).toEqual(legacyHeaders(input)); + expect(ntfyClient.buildSendNotificationHeaders({ ...input, markdown: false, cache: undefined })).toEqual( + legacyHeaders(input) + ); + }); + + it('sends the new optional headers only when set', () => { + const headers = ntfyClient.buildSendNotificationHeaders({ + message: 'm', + attach: ' https://example.com/f.pdf ', + filename: 'rapport-é.pdf', + markdown: true, + email: 'jane@example.com', + call: '+12223334444', + sequence_id: 'job-42', + cache: 'no', + firebase: 'no', + }); + expect(headers['X-Attach']).toBe('https://example.com/f.pdf'); + expect(decode(headers['X-Filename'])).toBe('rapport-é.pdf'); + expect(headers['X-Markdown']).toBe('yes'); + expect(headers['X-Email']).toBe('jane@example.com'); + expect(headers['X-Call']).toBe('+12223334444'); + expect(headers['X-Sequence-ID']).toBe('job-42'); + expect(headers['X-Cache']).toBe('no'); + expect(headers['X-Firebase']).toBe('no'); + }); + + it('encodes non-ASCII header values instead of passing bytes fetch rejects', () => { + const headers = ntfyClient.buildSendNotificationHeaders({ + message: 'm', + tags: ['✅', 'backup'], + actions: 'view, Öffnen, https://example.com', + delay: 'demain, 10h', + }); + expect(decode(headers['X-Tags'])).toBe('✅,backup'); + expect(decode(headers['X-Actions'])).toBe('view, Öffnen, https://example.com'); + expect(headers['X-Delay']).toBe('demain, 10h'); + expect(() => new Headers({ 'X-Tags': String(headers['X-Tags']), 'X-Actions': String(headers['X-Actions']) })).not.toThrow(); + }); + + it('leaves ASCII values with surrounding whitespace as 0.3.0 sent them and encodes multi-line ones', () => { + const trailing = ntfyClient.buildSendNotificationHeaders({ message: 'm', actions: 'view, Open, https://example.com\n' }); + expect(trailing['X-Actions']).toBe('view, Open, https://example.com\n'); + const multiLine = ntfyClient.buildSendNotificationHeaders({ + message: 'm', + actions: 'view, Open, https://example.com;\nview, Docs, https://docs.ntfy.sh', + }); + expect(decode(multiLine['X-Actions'])).toBe('view, Open, https://example.com;\nview, Docs, https://docs.ntfy.sh'); + }); + + it('accepts tags given as a comma-separated or JSON-array string', () => { + expect(ntfyClient.buildSendNotificationHeaders({ message: 'm', tags: 'a, b' })['X-Tags']).toBe('a,b'); + expect(ntfyClient.buildSendNotificationHeaders({ message: 'm', tags: '["a","b"]' })['X-Tags']).toBe('a,b'); + }); + + it('rejects a sequence id ntfy would refuse', () => { + expect(() => ntfyClient.buildSendNotificationHeaders({ message: 'm', sequence_id: 'has space' })).toThrow( + /Sequence ID/ + ); + }); +}); + +describe('buildJsonPublishBody', () => { + it('drops unset fields and types the rest', () => { + const body = ntfyClient.buildJsonPublishBody({ + topic: ' alerts ', + message: '**hi**', + title: ' ', + priority: '4', + tags: ['warning', ' ', 'test'], + actions: '[{"action":"view","label":"Open","url":"https://example.com"}]', + markdown: true, + sequence_id: 's1', + disable_cache: true, + disable_firebase: false, + }); + expect(body).toEqual({ + topic: 'alerts', + message: '**hi**', + priority: 4, + tags: ['warning', 'test'], + actions: [{ action: 'view', label: 'Open', url: 'https://example.com' }], + markdown: true, + sequence_id: 's1', + cache: 'no', + }); + }); + + it('validates priority, actions and topic', () => { + expect(() => ntfyClient.buildJsonPublishBody({ topic: 't', priority: 6 })).toThrow(/Priority/); + expect(() => ntfyClient.buildJsonPublishBody({ topic: 't', actions: '{bad' })).toThrow(/JSON array/); + expect(() => ntfyClient.buildJsonPublishBody({ topic: 't', actions: [{}, {}, {}, {}] })).toThrow(/at most 3/); + expect(() => ntfyClient.buildJsonPublishBody({ topic: '../v1/account' })).toThrow(/Topic/); + }); + + it('refuses Delay with Do Not Cache or Email To, which ntfy rejects with HTTP 400', () => { + expect(() => ntfyClient.buildJsonPublishBody({ topic: 't', delay: '2h', disable_cache: true })).toThrow( + /Do Not Cache cannot be combined with Delay/ + ); + expect(() => ntfyClient.buildJsonPublishBody({ topic: 't', delay: '2h', email: 'jane@example.com' })).toThrow( + /Email To cannot be combined with Delay/ + ); + expect(ntfyClient.buildJsonPublishBody({ topic: 't', delay: ' ', disable_cache: true })).toEqual({ + topic: 't', + cache: 'no', + }); + }); +}); + +describe('normalizeTags / normalizePriorityFilter', () => { + it('reads arrays, JSON-array strings and comma-separated strings', () => { + expect(ntfyClient.normalizeTags(['a', ' b '])).toEqual(['a', 'b']); + expect(ntfyClient.normalizeTags('["a","b"]')).toEqual(['a', 'b']); + expect(ntfyClient.normalizeTags('a, b')).toEqual(['a', 'b']); + expect(ntfyClient.normalizeTags('')).toBeUndefined(); + expect(ntfyClient.normalizePriorityFilter([4, '5'])).toEqual([4, 5]); + expect(ntfyClient.normalizePriorityFilter('[4,5]')).toEqual([4, 5]); + expect(ntfyClient.normalizePriorityFilter('4,5')).toEqual([4, 5]); + expect(ntfyClient.normalizePriorityFilter([])).toBeUndefined(); + expect(() => ntfyClient.normalizePriorityFilter('9')).toThrow(/Priority/); + }); + + it('splits a mapped "4,5" that the engine wraps into a one-item list', () => { + expect(ntfyClient.normalizePriorityFilter(['4,5'])).toEqual([4, 5]); + expect(ntfyClient.normalizeTags(['backup, warning'])).toEqual(['backup', 'warning']); + }); +}); + +describe('newestFirst', () => { + it('puts the later of two same-second messages first, as ntfy returns them oldest first', () => { + const ordered = ntfyClient.newestFirst([ + { id: 'a', time: 10, event: 'message', topic: 't' }, + { id: 'b', time: 20, event: 'message', topic: 't' }, + { id: 'c', time: 20, event: 'message', topic: 't' }, + ]); + expect(ordered.map((m) => m.id)).toEqual(['c', 'b', 'a']); + }); +}); + +describe('validateTopicList / baseUrl', () => { + it('accepts comma-separated topics and rejects path tricks', () => { + expect(ntfyClient.validateTopicList(' a , b_2,c-3 ')).toBe('a,b_2,c-3'); + expect(() => ntfyClient.validateTopicList('a,b/json')).toThrow(/Topic/); + expect(() => ntfyClient.validateTopicList('')).toThrow(/required/); + }); + + it('trims trailing slashes and refuses non-http URLs', () => { + const auth = (base_url: string): NtfyAuthValue => ({ + type: AppConnectionType.CUSTOM_AUTH, + props: { base_url, access_token: undefined }, + }); + expect(ntfyClient.baseUrl(auth(' https://ntfy.example.com/sub/ '))).toBe('https://ntfy.example.com/sub'); + expect(() => ntfyClient.baseUrl(auth('ftp://ntfy.example.com'))).toThrow(/https/); + expect(() => ntfyClient.baseUrl(auth('ntfy.example.com'))).toThrow(/not a valid URL/); + }); + + it('refuses a query, fragment or credentials that would swallow the API path', () => { + const auth = (base_url: string): NtfyAuthValue => ({ + type: AppConnectionType.CUSTOM_AUTH, + props: { base_url, access_token: undefined }, + }); + expect(() => ntfyClient.baseUrl(auth('https://ntfy.example.com/?tenant=a'))).toThrow(/"\?" or "#"/); + expect(() => ntfyClient.baseUrl(auth('https://ntfy.example.com/#x'))).toThrow(/"\?" or "#"/); + expect(() => ntfyClient.baseUrl(auth('https://ntfy.example.com?'))).toThrow(/"\?" or "#"/); + expect(() => ntfyClient.baseUrl(auth('https://user:pass@ntfy.example.com'))).toThrow(/user name or password/); + }); +}); + +describe('parseNdjson', () => { + it('parses one message per line and skips blank lines', () => { + const text = + '{"id":"a","time":1,"event":"message","topic":"t","message":"x"}\n\n{"id":"b","time":2,"event":"message_clear","topic":"t"}\n'; + expect(ntfyClient.parseNdjson(text).map((m) => m.id)).toEqual(['a', 'b']); + }); + + it('returns [] for an empty body and wraps an already-parsed object', () => { + expect(ntfyClient.parseNdjson('')).toEqual([]); + expect(ntfyClient.parseNdjson(undefined)).toEqual([]); + expect(ntfyClient.parseNdjson({ id: 'a', time: 1, event: 'message', topic: 't' })).toHaveLength(1); + }); + + it('throws on a line that is not JSON', () => { + expect(() => ntfyClient.parseNdjson('{"id":"a","time":1,"event":"message"}\nnot json')).toThrow(/line 2/); + }); +}); + +describe('advanceCursor (New Message trigger)', () => { + const msg = ({ id, time, event = 'message' }: { id: string; time: number; event?: string }): NtfyMessage => ({ + id, + time, + event, + topic: 't', + }); + + it('emits unseen messages oldest first and skips non-message events', () => { + const { newItems, cursor } = ntfyClient.advanceCursor({ + cursor: { lastTime: 1000, seen: [] }, + fetched: [msg({ id: 'b', time: 1002 }), msg({ id: 'a', time: 1001 }), msg({ id: 'c', time: 1003, event: 'message_delete' })], + }); + expect(newItems.map((m) => m.id)).toEqual(['a', 'b']); + expect(cursor.lastTime).toBe(1002); + expect(cursor.seen.map((s) => s.id).sort()).toEqual(['a', 'b']); + }); + + it('does not re-emit messages returned again by the overlap window', () => { + const first = ntfyClient.advanceCursor({ + cursor: { lastTime: 1000, seen: [] }, + fetched: [msg({ id: 'a', time: 1001 }), msg({ id: 'b', time: 1001 })], + }); + const second = ntfyClient.advanceCursor({ + cursor: first.cursor, + fetched: [msg({ id: 'a', time: 1001 }), msg({ id: 'b', time: 1001 }), msg({ id: 'c', time: 1001 })], + }); + expect(second.newItems.map((m) => m.id)).toEqual(['c']); + }); + + it('catches a scheduled message delivered with an earlier time than the newest one', () => { + const first = ntfyClient.advanceCursor({ + cursor: { lastTime: 1000, seen: [] }, + fetched: [msg({ id: 'late-writer', time: 1030 })], + }); + const second = ntfyClient.advanceCursor({ + cursor: first.cursor, + fetched: [msg({ id: 'sched', time: 1020 }), msg({ id: 'late-writer', time: 1030 })], + }); + expect(second.newItems.map((m) => m.id)).toEqual(['sched']); + }); + + it('ignores messages older than the overlap window and keeps the seen list bounded', () => { + const overlap = ntfyClient.CURSOR_OVERLAP_SECONDS; + const { newItems, cursor } = ntfyClient.advanceCursor({ + cursor: { lastTime: 5000, seen: [{ id: 'old', time: 5000 - overlap - 10 }, { id: 'recent', time: 4990 }] }, + fetched: [msg({ id: 'ancient', time: 100 }), msg({ id: 'new', time: 5000 + overlap + 5 })], + }); + expect(newItems.map((m) => m.id)).toEqual(['new']); + expect(cursor.seen.map((s) => s.id)).toEqual(['new']); + }); +}); + +describe('toNtfyError', () => { + it('turns an ntfy JSON error into a readable message with the status', () => { + const error = ntfyClient.toNtfyError({ + response: { status: 401, body: { code: 40101, http: 401, error: 'unauthorized' } }, + }); + expect(error).toBeInstanceOf(Error); + expect(String(error)).toMatch(/HTTP 401: unauthorized \(ntfy code 40101\).*Access Token/); + }); + + it('rethrows errors without an HTTP status unchanged', () => { + const original = new Error('socket hang up'); + expect(ntfyClient.toNtfyError(original)).toBe(original); + }); +}); diff --git a/packages/pieces/community/ntfy/test/helpers.ts b/packages/pieces/community/ntfy/test/helpers.ts new file mode 100644 index 000000000000..5ab5b1164a0d --- /dev/null +++ b/packages/pieces/community/ntfy/test/helpers.ts @@ -0,0 +1,64 @@ +import { + ActionContext, + AppConnectionType, + InputPropertyMap, + StaticPropsValue, + TriggerHookContext, + TriggerStrategy, +} from '@activepieces/pieces-framework'; +import { createMockActionContext, createMockPollingTriggerContext } from '../../../framework/src/lib/test'; +import { ntfyAuth } from '../src/lib/auth'; + +export function runAction({ + action, + propsValue, + token, + baseUrl, +}: { + action: { run: (context: ActionContext) => Promise }; + propsValue: StaticPropsValue; + token?: string; + baseUrl?: string; +}) { + return action.run({ + ...createMockActionContext({ propsValue }), + auth: testAuth({ token, baseUrl }), + }); +} + +export function triggerContext({ + propsValue, + store, +}: { + propsValue: StaticPropsValue; + store: Map; +}): TriggerHookContext { + return { + ...createMockPollingTriggerContext({ propsValue }), + auth: testAuth(), + store: { + put: async (key: string, value: T) => { + store.set(key, JSON.stringify(value)); + return value; + }, + get: async (key: string): Promise => { + const raw = store.get(key); + return raw === undefined ? null : JSON.parse(raw); + }, + delete: async (key: string) => { + store.delete(key); + }, + }, + }; +} + +export function testAuth({ token, baseUrl = 'https://ntfy.example.com/' }: { token?: string; baseUrl?: string } = {}) { + return { + type: AppConnectionType.CUSTOM_AUTH as const, + props: { base_url: baseUrl, access_token: token }, + }; +} + +export function ndjson(lines: Record[]): string { + return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; +} diff --git a/packages/pieces/community/ntfy/vitest.config.ts b/packages/pieces/community/ntfy/vitest.config.ts new file mode 100644 index 000000000000..79c972619047 --- /dev/null +++ b/packages/pieces/community/ntfy/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'), + }, + }, +}) diff --git a/packages/pieces/community/zoho-crm/package.json b/packages/pieces/community/zoho-crm/package.json index a5e2aa42e142..43cc333b9c06 100644 --- a/packages/pieces/community/zoho-crm/package.json +++ b/packages/pieces/community/zoho-crm/package.json @@ -1,21 +1,22 @@ { "name": "@activepieces/piece-zoho-crm", - "version": "0.3.1", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "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" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", "@activepieces/pieces-framework": "workspace:*", - "dayjs": "1.11.9", "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/community/zoho-crm/src/i18n/translation.json b/packages/pieces/community/zoho-crm/src/i18n/translation.json index 4c1a59e5002f..e2be28028ef9 100644 --- a/packages/pieces/community/zoho-crm/src/i18n/translation.json +++ b/packages/pieces/community/zoho-crm/src/i18n/translation.json @@ -10,32 +10,330 @@ "zohocloud.ca (Canada)": "zohocloud.ca (Canada)", "Authentication for Zoho CRM": "Authentication for Zoho CRM", "Read file": "Read file", + "Create Record": "Create Record", + "Update Record": "Update Record", + "Get Record": "Get Record", + "Create or Update Record": "Create or Update Record", + "Delete Record": "Delete Record", + "Convert Lead": "Convert Lead", + "Add Note": "Add Note", + "Add Tags to Record": "Add Tags to Record", + "Upload Attachment": "Upload Attachment", + "List Modules": "List Modules", + "Get Module Fields": "Get Module Fields", + "Get Related Lists": "Get Related Lists", + "List Users": "List Users", + "Get User": "Get User", + "Get Current User": "Get Current User", + "List Records": "List Records", + "Upsert Record": "Upsert Record", + "Create Lead": "Create Lead", + "Create Contact": "Create Contact", + "Create Deal": "Create Deal", + "Create Event": "Create Event", + "List Notes": "List Notes", + "Get Note": "Get Note", + "Create Note": "Create Note", + "Update Note": "Update Note", + "Delete Note": "Delete Note", + "List Tags": "List Tags", + "Create Tag": "Create Tag", + "Add Tags": "Add Tags", + "Remove Tags": "Remove Tags", + "List Related Records": "List Related Records", + "Link Related Record": "Link Related Record", + "List Attachments": "List Attachments", + "Attach Link": "Attach Link", + "Download Attachment": "Download Attachment", + "Create Email Draft": "Create Email Draft", + "List Email Drafts": "List Email Drafts", + "Update Email Draft": "Update Email Draft", "Custom API Call": "Custom API Call", "Download a file content from Zoho CRM. e.g.: a Backup File": "Download a file content from Zoho CRM. e.g.: a Backup File", + "Creates a record in any module, including custom fields.": "Creates a record in any module, including custom fields.", + "Updates fields of a record in any module. Fields left empty are not changed.": "Updates fields of a record in any module. Fields left empty are not changed.", + "Gets a record by id from any module, with all its fields.": "Gets a record by id from any module, with all its fields.", + "Updates the record that matches on the duplicate-check fields, or creates it if none matches.": "Updates the record that matches on the duplicate-check fields, or creates it if none matches.", + "Deletes a record (it moves to the Zoho CRM Recycle Bin).": "Deletes a record (it moves to the Zoho CRM Recycle Bin).", + "Converts a lead into a contact and account, and optionally a deal.": "Converts a lead into a contact and account, and optionally a deal.", + "Adds a note to a record.": "Adds a note to a record.", + "Adds existing or new tags to a record. New tag names are created first.": "Adds existing or new tags to a record. New tag names are created first.", + "Attaches a file to a record.": "Attaches a file to a record.", + "Lists the modules available through the API.": "Lists the modules available through the API.", + "Lists the fields of a module, custom fields included.": "Lists the fields of a module, custom fields included.", + "Lists the related lists of a module.": "Lists the related lists of a module.", + "Lists CRM users.": "Lists CRM users.", + "Gets a CRM user by id.": "Gets a CRM user by id.", + "Gets the connected CRM user.": "Gets the connected CRM user.", + "Lists records of a module, one page at a time.": "Lists records of a module, one page at a time.", + "Gets one record by id.": "Gets one record by id.", + "Creates a record in any module from a JSON object of fields.": "Creates a record in any module from a JSON object of fields.", + "Updates the given fields of a record; other fields are unchanged.": "Updates the given fields of a record; other fields are unchanged.", + "Deletes a record (moves it to the Recycle Bin).": "Deletes a record (moves it to the Recycle Bin).", + "Updates the matching record or creates it.": "Updates the matching record or creates it.", + "Creates a lead.": "Creates a lead.", + "Creates a contact.": "Creates a contact.", + "Creates a deal.": "Creates a deal.", + "Creates a calendar event (meeting).": "Creates a calendar event (meeting).", + "Converts a lead to a contact, account and optional deal.": "Converts a lead to a contact, account and optional deal.", + "Lists notes of a record, or all notes.": "Lists notes of a record, or all notes.", + "Gets a note by id.": "Gets a note by id.", + "Changes the title or content of a note.": "Changes the title or content of a note.", + "Deletes a note.": "Deletes a note.", + "Lists the tags of a module.": "Lists the tags of a module.", + "Creates a tag in a module, or returns it if it exists.": "Creates a tag in a module, or returns it if it exists.", + "Adds tags to one or more records.": "Adds tags to one or more records.", + "Removes tags from one or more records.": "Removes tags from one or more records.", + "Lists records in a related list of a record.": "Lists records in a related list of a record.", + "Associates a record with another record through a related list.": "Associates a record with another record through a related list.", + "Lists the attachments of a record.": "Lists the attachments of a record.", + "Attaches a URL to a record as a link attachment.": "Attaches a URL to a record as a link attachment.", + "Downloads a file attachment of a record.": "Downloads a file attachment of a record.", + "Saves an email draft on a record without sending it.": "Saves an email draft on a record without sending it.", + "Lists the email drafts saved on a record.": "Lists the email drafts saved on a record.", + "Changes an email draft; fields you leave empty keep their value.": "Changes an email draft; fields you leave empty keep their value.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "URL": "URL", + "Module": "Module", + "Fields": "Fields", + "Additional Fields (JSON)": "Additional Fields (JSON)", + "Automation to Run": "Automation to Run", + "Skip All Automation": "Skip All Automation", + "Record": "Record", + "Clear Fields": "Clear Fields", + "Duplicate Check Fields": "Duplicate Check Fields", + "Run Workflows": "Run Workflows", + "Lead": "Lead", + "Existing Account": "Existing Account", + "Existing Contact": "Existing Contact", + "Overwrite Contact's Account": "Overwrite Contact's Account", + "Owner": "Owner", + "Notify Lead Owner": "Notify Lead Owner", + "Notify New Owner": "Notify New Owner", + "Create a Deal": "Create a Deal", + "Deal Name": "Deal Name", + "Deal Stage": "Deal Stage", + "Deal Closing Date": "Deal Closing Date", + "Deal Amount": "Deal Amount", + "Deal Pipeline": "Deal Pipeline", + "Other Deal Fields (JSON)": "Other Deal Fields (JSON)", + "Title": "Title", + "Content": "Content", + "Existing Tags": "Existing Tags", + "New Tag Names": "New Tag Names", + "File": "File", + "File Name": "File Name", + "Module API Name": "Module API Name", + "Include Read-Only Fields": "Include Read-Only Fields", + "User Type": "User Type", + "Page": "Page", + "Per Page": "Per Page", + "User ID": "User ID", + "Page Token": "Page Token", + "Sort By": "Sort By", + "Sort Order": "Sort Order", + "Custom View ID": "Custom View ID", + "Record ID": "Record ID", + "Data": "Data", + "Skip Automation": "Skip Automation", + "Append Multi-Select Fields": "Append Multi-Select Fields", + "Last Name": "Last Name", + "First Name": "First Name", + "Company": "Company", + "Email": "Email", + "Phone": "Phone", + "Mobile": "Mobile", + "Website": "Website", + "Lead Source": "Lead Source", + "Lead Status": "Lead Status", + "Description": "Description", + "Owner User ID": "Owner User ID", + "Additional Fields": "Additional Fields", + "Account ID": "Account ID", + "Mailing Street": "Mailing Street", + "Mailing City": "Mailing City", + "Mailing State": "Mailing State", + "Mailing Zip": "Mailing Zip", + "Mailing Country": "Mailing Country", + "Stage": "Stage", + "Closing Date": "Closing Date", + "Amount": "Amount", + "Pipeline": "Pipeline", + "Contact ID": "Contact ID", + "Start": "Start", + "End": "End", + "All Day": "All Day", + "Venue": "Venue", + "Related Module": "Related Module", + "Related Record ID": "Related Record ID", + "Lead ID": "Lead ID", + "Existing Account ID": "Existing Account ID", + "Existing Contact ID": "Existing Contact ID", + "Overwrite Contact Account": "Overwrite Contact Account", + "Parent Module": "Parent Module", + "Parent Record ID": "Parent Record ID", + "Note ID": "Note ID", + "Tag Name": "Tag Name", + "Color": "Color", + "Record IDs": "Record IDs", + "Tag Names": "Tag Names", + "Related List API Name": "Related List API Name", + "Relation Fields": "Relation Fields", + "Attachment URL": "Attachment URL", + "Attachment ID": "Attachment ID", + "From": "From", + "To": "To", + "Cc": "Cc", + "Bcc": "Bcc", + "Subject": "Subject", + "Body": "Body", + "Content Format": "Content Format", + "Reply To": "Reply To", + "Draft ID": "Draft ID", "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", - "The full URL to use, including the base URL": "The full URL to use, including the base URL", + "Full https URL on your Zoho API host (/crm/...) or download host.": "Full https URL on your Zoho API host (/crm/...) or download host.", + "The Zoho CRM module (for example Leads, Contacts, Deals or a custom module).": "The Zoho CRM module (for example Leads, Contacts, Deals or a custom module).", + "Standard and custom fields of the module. Read-only, formula, subform and file fields are not listed.": "Standard and custom fields of the module. Read-only, formula, subform and file fields are not listed.", + "Optional raw fields by API name, for fields the form does not list (e.g. subform rows). Values set in the form above win over the same key here.": "Optional raw fields by API name, for fields the form does not list (e.g. subform rows). Values set in the form above win over the same key here.", + "Empty runs Zoho's default workflows; pick some to run only those.": "Empty runs Zoho's default workflows; pick some to run only those.", + "Run no workflow, approval or blueprint for this record.": "Run no workflow, approval or blueprint for this record.", + "Pick one of the 200 most recently modified records, or map a record id from an earlier step.": "Pick one of the 200 most recently modified records, or map a record id from an earlier step.", + "Only the fields you fill in are changed. Use \"Clear Fields\" to blank a field.": "Only the fields you fill in are changed. Use \"Clear Fields\" to blank a field.", + "Field API names to set to empty (e.g. \"Phone\", \"Custom_Field__c\").": "Field API names to set to empty (e.g. \"Phone\", \"Custom_Field__c\").", + "Run no workflow, approval or blueprint for this update.": "Run no workflow, approval or blueprint for this update.", + "Fields used to find an existing record. Leave empty to use Zoho's default (Email for Leads/Contacts, Account_Name for Accounts, Deal_Name for Deals, Name for custom modules).": "Fields used to find an existing record. Leave empty to use Zoho's default (Email for Leads/Contacts, Account_Name for Accounts, Deal_Name for Deals, Name for custom modules).", + "Run delete workflows configured in Zoho (Zoho default: on).": "Run delete workflows configured in Zoho (Zoho default: on).", + "Link to this account; the lead's Company must match its name.": "Link to this account; the lead's Company must match its name.", + "Merge into this contact; the lead's name and email must match.": "Merge into this contact; the lead's name and email must match.", + "Move the existing contact to the given account.": "Move the existing contact to the given account.", + "Active user who owns the new records; empty keeps the lead owner.": "Active user who owns the new records; empty keeps the lead owner.", + "A Stage picklist value of your org, e.g. \"Qualification\".": "A Stage picklist value of your org, e.g. \"Qualification\".", + "Date as yyyy-MM-dd, e.g. 2026-12-31.": "Date as yyyy-MM-dd, e.g. 2026-12-31.", + "Required when your org uses pipelines, e.g. \"Standard (Standard)\".": "Required when your org uses pipelines, e.g. \"Standard (Standard)\".", + "Tags already defined for the module.": "Tags already defined for the module.", + "Tag names to create (if missing) and add.": "Tag names to create (if missing) and add.", + "Optional. Overrides the file name.": "Optional. Overrides the file name.", + "Module API name from List Modules, e.g. \"Leads\", \"Contacts\", \"Deals\", \"Accounts\" or \"CustomModule1\". Not the display label.": "Module API name from List Modules, e.g. \"Leads\", \"Contacts\", \"Deals\", \"Accounts\" or \"CustomModule1\". Not the display label.", + "Also list fields that cannot be written (formula, system, auto-number).": "Also list fields that cannot be written (formula, system, auto-number).", + "Page number, starting at 1.": "Page number, starting at 1.", + "1 to 200 (default 200).": "1 to 200 (default 200).", + "Numeric user id, e.g. \"5725767000000411001\".": "Numeric user id, e.g. \"5725767000000411001\".", + "Module API name from List Modules, e.g. \"Leads\", \"Contacts\", \"Deals\" or \"CustomModule1\".": "Module API name from List Modules, e.g. \"Leads\", \"Contacts\", \"Deals\" or \"CustomModule1\".", + "Field API names to return (max 50). Leave empty for id, then the custom fields of the module, then its standard fields (50 in total).": "Field API names to return (max 50). Leave empty for id, then the custom fields of the module, then its standard fields (50 in total).", + "1 to 200. Default 50.": "1 to 200. Default 50.", + "Page number for the first 2,000 records. Do not combine with page_token.": "Page number for the first 2,000 records. Do not combine with page_token.", + "next_page_token from a previous call, needed beyond 2,000 records.": "next_page_token from a previous call, needed beyond 2,000 records.", + "Optional id of a custom view (cvid) to list only its records.": "Optional id of a custom view (cvid) to list only its records.", + "Numeric Zoho record id, e.g. \"5725767000000524157\" (from List Records or a create result).": "Numeric Zoho record id, e.g. \"5725767000000524157\" (from List Records or a create result).", + "Optional field API names to return; empty returns all fields.": "Optional field API names to return; empty returns all fields.", + "JSON object of field API names to values, e.g. {\"Last_Name\": \"Doe\", \"Email\": \"jane@example.com\", \"Owner\": {\"id\": \"5725767000000411001\"}}. Get API names and picklist values from Get Module Fields. Dates as \"2026-10-01\", date-times as \"2026-10-01T09:00:00+02:00\", lookups as {\"id\": \"...\"}.": "JSON object of field API names to values, e.g. {\"Last_Name\": \"Doe\", \"Email\": \"jane@example.com\", \"Owner\": {\"id\": \"5725767000000411001\"}}. Get API names and picklist values from Get Module Fields. Dates as \"2026-10-01\", date-times as \"2026-10-01T09:00:00+02:00\", lookups as {\"id\": \"...\"}.", + "When true, no workflow, approval or blueprint runs for this change. Default false (Zoho runs its automation).": "When true, no workflow, approval or blueprint runs for this change. Default false (Zoho runs its automation).", + "Multi-select picklist API names whose new values should be added to the current ones instead of replacing them.": "Multi-select picklist API names whose new values should be added to the current ones instead of replacing them.", + "Default true.": "Default true.", + "Field API names to match on, e.g. [\"Email\"]. Empty uses the module default.": "Field API names to match on, e.g. [\"Email\"]. Empty uses the module default.", + "Required in many orgs' layouts.": "Required in many orgs' layouts.", + "Job title (Designation).": "Job title (Designation).", + "A Lead_Source picklist value of your org, e.g. \"Web Download\".": "A Lead_Source picklist value of your org, e.g. \"Web Download\".", + "A Lead_Status picklist value of your org, e.g. \"Not Contacted\".": "A Lead_Status picklist value of your org, e.g. \"Not Contacted\".", + "Optional user id (from List Users or Get Current User). Default: the connected user.": "Optional user id (from List Users or Get Current User). Default: the connected user.", + "Optional JSON of other field API names, including custom fields, e.g. {\"Custom_Field__c\": \"x\"}. The named props above win over the same key here.": "Optional JSON of other field API names, including custom fields, e.g. {\"Custom_Field__c\": \"x\"}. The named props above win over the same key here.", + "Id of an existing Accounts record.": "Id of an existing Accounts record.", + "yyyy-MM-dd, e.g. \"2026-12-31\". Required in many layouts.": "yyyy-MM-dd, e.g. \"2026-12-31\". Required in many layouts.", + "Pipeline name when the org uses pipelines, e.g. \"Standard (Standard)\".": "Pipeline name when the org uses pipelines, e.g. \"Standard (Standard)\".", + "ISO 8601 with offset, e.g. \"2026-10-01T09:00:00+02:00\".": "ISO 8601 with offset, e.g. \"2026-10-01T09:00:00+02:00\".", + "ISO 8601 with offset, after Start.": "ISO 8601 with offset, after Start.", + "Module API name of the record to link (What_Id), e.g. \"Deals\" or \"Accounts\". Give with related_record_id.": "Module API name of the record to link (What_Id), e.g. \"Deals\" or \"Accounts\". Give with related_record_id.", + "Set to also create a deal.": "Set to also create a deal.", + "yyyy-MM-dd.": "yyyy-MM-dd.", + "Module API name of the record, e.g. \"Leads\". Give with parent_id.": "Module API name of the record, e.g. \"Leads\". Give with parent_id.", + "1 to 200 (default 50).": "1 to 200 (default 50).", + "Numeric note id from List Notes.": "Numeric note id from List Notes.", + "Module API name from List Modules, e.g. \"Leads\" or \"Deals\".": "Module API name from List Modules, e.g. \"Leads\" or \"Deals\".", + "Numeric record ids (max 500).": "Numeric record ids (max 500).", + "Tag names, e.g. [\"VIP\", \"Webinar 2026\"].": "Tag names, e.g. [\"VIP\", \"Webinar 2026\"].", + "Module API name of the parent record, e.g. \"Accounts\" or \"Leads\".": "Module API name of the parent record, e.g. \"Accounts\" or \"Leads\".", + "Numeric id of the parent record.": "Numeric id of the parent record.", + "api_name from Get Related Lists, e.g. \"Contacts\", \"Deals\", \"Campaigns\", \"Products\".": "api_name from Get Related Lists, e.g. \"Contacts\", \"Deals\", \"Campaigns\", \"Products\".", + "Field API names to return (max 50).": "Field API names to return (max 50).", + "next_page_token from a previous call (beyond 2,000 records).": "next_page_token from a previous call (beyond 2,000 records).", + "Id of the record to link.": "Id of the record to link.", + "Optional fields of the relation, e.g. {\"Member_Status\": \"Invited\"}.": "Optional fields of the relation, e.g. {\"Member_Status\": \"Invited\"}.", + "Full URL, e.g. \"https://example.com/proposal.pdf\".": "Full URL, e.g. \"https://example.com/proposal.pdf\".", + "Module of the record, e.g. \"Leads\", \"Contacts\", \"Deals\" or \"Accounts\".": "Module of the record, e.g. \"Leads\", \"Contacts\", \"Deals\" or \"Accounts\".", + "Numeric id of the record the draft belongs to.": "Numeric id of the record the draft belongs to.", + "Sender email, e.g. the connected user's email.": "Sender email, e.g. the connected user's email.", + "Recipient emails.": "Recipient emails.", + "HTML (rich text) or plain text. Default HTML.": "HTML (rich text) or plain text. Default HTML.", + "Optional: return only this draft.": "Optional: return only this draft.", + "1 to 10 (Zoho maximum 10).": "1 to 10 (Zoho maximum 10).", + "Draft id from List Email Drafts (a long hex string).": "Draft id from List Email Drafts (a long hex string).", + "Replaces the To list; omit it to keep the current list.": "Replaces the To list; omit it to keep the current list.", + "Replaces the Cc list; omit it to keep the current list.": "Replaces the Cc list; omit it to keep the current list.", + "Replaces the Bcc list; omit it to keep the current list.": "Replaces the Bcc list; omit it to keep the current list.", + "Leave empty to keep the current format.": "Leave empty to keep the current format.", "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.", + "workflow": "workflow", + "approval": "approval", + "blueprint": "blueprint", + "pathfinder": "pathfinder", + "orchestration": "orchestration", + "AllUsers": "AllUsers", + "ActiveUsers": "ActiveUsers", + "DeactiveUsers": "DeactiveUsers", + "ConfirmedUsers": "ConfirmedUsers", + "NotConfirmedUsers": "NotConfirmedUsers", + "DeletedUsers": "DeletedUsers", + "ActiveConfirmedUsers": "ActiveConfirmedUsers", + "AdminUsers": "AdminUsers", + "ActiveConfirmedAdmins": "ActiveConfirmedAdmins", + "id": "id", + "Created_Time": "Created_Time", + "Modified_Time": "Modified_Time", + "Descending": "Descending", + "Ascending": "Ascending", + "#F17574": "#F17574", + "#F48435": "#F48435", + "#E7A826": "#E7A826", + "#A8C026": "#A8C026", + "#63C57E": "#63C57E", + "#1DB9B4": "#1DB9B4", + "#57B1FD": "#57B1FD", + "#879BFC": "#879BFC", + "#D297EE": "#D297EE", + "#FD87BD": "#FD87BD", + "#969696": "#969696", + "#658BA8": "#658BA8", + "#B88562": "#B88562", + "HTML": "HTML", + "Plain text": "Plain text", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw", "New Contact": "New Contact", - "Triggers when a new contact is created": "Triggers when a new contact is created" + "New Record": "New Record", + "Updated Record": "Updated Record", + "Triggers when a new contact is created": "Triggers when a new contact is created", + "Triggers when a record is created in any module (custom fields included).": "Triggers when a record is created in any module (custom fields included).", + "Triggers when a record is modified in any module.": "Triggers when a record is modified in any module.", + "Fields to Include": "Fields to Include", + "Include Newly Created Records": "Include Newly Created Records", + "Optional. Up to 4 extra Contacts fields (for example custom fields) to include. Zoho returns at most 50 fields per record and this trigger already requests 46 standard ones.": "Optional. Up to 4 extra Contacts fields (for example custom fields) to include. Zoho returns at most 50 fields per record and this trigger already requests 46 standard ones.", + "The module to watch (standard or custom).": "The module to watch (standard or custom).", + "Optional, up to 47. Leave empty for id, then custom, then standard fields (50 in total).": "Optional, up to 47. Leave empty for id, then custom, then standard fields (50 in total).", + "Also fire for records created but not edited since the last check.": "Also fire for records created but not edited since the last check." } \ No newline at end of file diff --git a/packages/pieces/community/zoho-crm/src/index.ts b/packages/pieces/community/zoho-crm/src/index.ts index d6bcc9dfe9ca..3500cd919da9 100644 --- a/packages/pieces/community/zoho-crm/src/index.ts +++ b/packages/pieces/community/zoho-crm/src/index.ts @@ -1,13 +1,21 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { - OAuth2PropertyValue, - PieceAuth, - Property, - createPiece, -} from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { newContact } from './lib/triggers/new-contact'; +import { newRecordTrigger } from './lib/triggers/new-record'; +import { updatedRecordTrigger } from './lib/triggers/updated-record'; import { readFile } from './lib/actions/read-file'; +import { createRecordAction } from './lib/actions/create-record'; +import { updateRecordAction } from './lib/actions/update-record'; +import { getRecordAction } from './lib/actions/get-record'; +import { upsertRecordAction } from './lib/actions/upsert-record'; +import { deleteRecordAction } from './lib/actions/delete-record'; +import { convertLeadAction } from './lib/actions/convert-lead'; +import { addNoteAction } from './lib/actions/add-note'; +import { addTagsToRecordAction } from './lib/actions/add-tags-to-record'; +import { uploadAttachmentAction } from './lib/actions/upload-attachment'; +import { zohoAiActions } from './lib/actions/ai'; +import { customApiAuthHeaders, getApiDomain, readField } from './lib/common/client'; import { zohoCrmAuth } from './lib/auth'; export const zohoCrm = createPiece({ @@ -15,24 +23,37 @@ export const zohoCrm = createPiece({ description: 'Customer relationship management software', logoUrl: 'https://cdn.activepieces.com/pieces/zoho-crm.png', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', categories: [PieceCategory.SALES_AND_CRM], authors: ["kishanprmr","MoShizzle","khaledmashaly","abuaboud","ikus060"], auth: zohoCrmAuth, actions: [ readFile, + createRecordAction, + updateRecordAction, + getRecordAction, + upsertRecordAction, + deleteRecordAction, + convertLeadAction, + addNoteAction, + addTagsToRecordAction, + uploadAttachmentAction, + ...zohoAiActions, createCustomApiCallAction({ - baseUrl: (auth) => - { - const data = (auth as OAuth2PropertyValue).data; - return data && data['api_domain']? `${data['api_domain']}/crm/v3` : '' - }, - + baseUrl: (auth) => { + if (!auth) { + return ''; + } + try { + return `${getApiDomain(auth)}/crm/v3`; + } catch { + return ''; + } + }, + auth: zohoCrmAuth, - authMapping: async (auth) => ({ - Authorization: `Zoho-oauthtoken ${(auth as OAuth2PropertyValue).access_token}`, - }), + authMapping: async (auth, propsValue) => customApiAuthHeaders({ auth, url: readField({ value: propsValue['url'], key: 'url' }) }), }), ], - triggers: [newContact], + triggers: [newContact, newRecordTrigger, updatedRecordTrigger], }); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/add-note.ts b/packages/pieces/community/zoho-crm/src/lib/actions/add-note.ts new file mode 100644 index 000000000000..32733a59bdd7 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/add-note.ts @@ -0,0 +1,36 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName, requireId } from '../common/client'; +import { moduleDropdown, recordDropdown } from '../common/props'; +import { createNote } from '../common/records'; +import { childWriteOutputSchema } from '../output-schemas'; + +export const addNoteAction = createAction({ + auth: zohoCrmAuth, + name: 'add_note', + classification: 'WRITE', + displayName: 'Add Note', + description: 'Adds a note to a record.', + audience: 'human', + aiMetadata: { + description: + 'Adds a note (optional title plus content) to one Zoho CRM record in any module. Use to log a call summary or context on a lead, contact, deal or account. Not idempotent: each call adds another note.', + idempotent: false, + }, + props: { + module: moduleDropdown(), + record_id: recordDropdown(), + title: Property.ShortText({ displayName: 'Title', required: false }), + content: Property.LongText({ displayName: 'Content', required: true }), + }, + outputSchema: childWriteOutputSchema, + async run({ auth, propsValue }) { + return createNote({ + auth, + module: requireApiName({ value: propsValue.module, name: 'Module' }), + recordId: requireId({ value: propsValue.record_id, name: 'Record' }), + title: propsValue.title, + content: propsValue.content, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/add-tags-to-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/add-tags-to-record.ts new file mode 100644 index 000000000000..710b07084e83 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/add-tags-to-record.ts @@ -0,0 +1,48 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { ZohoCrmError, requireApiName, requireId, stringList } from '../common/client'; +import { moduleDropdown, recordDropdown, tagsDropdown } from '../common/props'; +import { changeTags, ensureTag } from '../common/records'; +import { tagChangeOutputSchema } from '../output-schemas'; + +export const addTagsToRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'add_tags_to_record', + classification: 'WRITE', + displayName: 'Add Tags to Record', + description: 'Adds existing or new tags to a record. New tag names are created first.', + audience: 'human', + aiMetadata: { + description: + 'Adds tags to one Zoho CRM record, creating any tag name that does not exist yet in the module. Existing tags on the record are kept. Idempotent: adding a tag the record already has changes nothing.', + idempotent: true, + }, + props: { + module: moduleDropdown(), + record_id: recordDropdown(), + tags: tagsDropdown(), + new_tags: Property.Array({ + displayName: 'New Tag Names', + description: 'Tag names to create (if missing) and add.', + required: false, + }), + }, + outputSchema: tagChangeOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module, name: 'Module' }); + const recordId = requireId({ value: propsValue.record_id, name: 'Record' }); + const existing = stringList(propsValue.tags); + const created = stringList(propsValue.new_tags); + const names = [...new Set([...existing, ...created])]; + if (names.length === 0) { + throw new ZohoCrmError('Choose at least one tag or enter a new tag name.'); + } + if (names.length > 10) { + throw new ZohoCrmError('Zoho allows at most 10 tags per record.'); + } + for (const name of created) { + await ensureTag({ auth, module, name }); + } + return changeTags({ auth, module, recordIds: [recordId], tags: names.map((name) => ({ name })), mode: 'add' }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/drafts.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/drafts.ts new file mode 100644 index 000000000000..ff880926d46e --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/drafts.ts @@ -0,0 +1,271 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { + ZohoAuth, + ZohoCrmError, + optionalInt, + readField, + readString, + requireApiName, + requireId, + requireKey, + stringList, + unwrapWriteItem, + zohoRequest, + validatePaging, +} from '../../common/client'; +import { flattenDraft } from '../../common/flatten'; +import { draftWriteOutputSchema, listDraftsOutputSchema } from '../../output-schemas-ai'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const moduleApiName = Property.ShortText({ + displayName: 'Module API Name', + description: 'Module of the record, e.g. "Leads", "Contacts", "Deals" or "Accounts".', + required: true, +}); +const recordId = Property.ShortText({ displayName: 'Record ID', description: 'Numeric id of the record the draft belongs to.', required: true }); +const draftId = Property.ShortText({ displayName: 'Draft ID', description: 'Draft id from List Email Drafts (a long hex string).', required: true }); + +function email({ value, name }: { value: unknown; name: string }): string { + const s = typeof value === 'string' ? value.trim() : ''; + if (!EMAIL_RE.test(s)) throw new ZohoCrmError(`${name} must be an email address.`); + return s; +} + +function recipients({ value, name }: { value: unknown; name: string }): { email: string }[] | undefined { + if (value === undefined || value === null) return undefined; + return stringList(value).map((v) => ({ email: email({ value: v, name }) })); +} + +function replacementRecipients({ value, name }: { value: unknown; name: string }): { email: string }[] | undefined { + const list = recipients({ value, name }); + if (list !== undefined && list.length === 0) { + throw new ZohoCrmError(`${name} is empty. Zoho keeps a draft's ${name} recipients when asked to remove them all, so pass the new addresses, or leave ${name} out to keep the current list.`); + } + return list; +} + +function draftPath({ module, id }: { module: string; id: string }): string { + return `/${encodeURIComponent(module)}/${id}/__email_drafts`; +} + +async function fetchDraft({ auth, module, id, draft }: { auth: ZohoAuth; module: string; id: string; draft: string }): Promise> { + const body = await zohoRequest<{ __email_drafts?: Record[] }>({ + auth, + method: HttpMethod.GET, + path: `${draftPath({ module, id })}/${draft}`, + }); + const found = body?.__email_drafts?.[0]; + if (!found) throw new ZohoCrmError(`No draft ${draft} on ${module} ${id}.`, 404, 'NOT_FOUND'); + return found; +} + +function toRecipientObjects(value: unknown): { email: string; user_name?: string }[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.flatMap((v) => { + if (typeof v === 'string') return [{ email: v }]; + const address = readString(readField({ value: v, key: 'email' })); + if (address === undefined) return []; + const userName = readString(readField({ value: v, key: 'user_name' })); + return [{ email: address, ...(userName !== undefined ? { user_name: userName } : {}) }]; + }); +} + +function emailFrom(value: unknown): string | undefined { + if (typeof value === 'string') return value; + return readString(readField({ value, key: 'email' })); +} + +export function mergeDraft({ + current, + changes, + draft, +}: { + current: Record; + changes: DraftChanges; + draft: string; +}): Record { + const merged: Record = { + id: draft, + from: changes.from ?? emailFrom(current['from']), + rich_text: changes.rich_text ?? (typeof current['rich_text'] === 'boolean' ? current['rich_text'] : true), + }; + const to = changes.to ?? toRecipientObjects(current['to']); + const cc = changes.cc ?? toRecipientObjects(current['cc']); + const bcc = changes.bcc ?? toRecipientObjects(current['bcc']); + if (to) merged['to'] = to; + if (cc) merged['cc'] = cc; + if (bcc) merged['bcc'] = bcc; + const subject = changes.subject ?? current['subject']; + const content = changes.content ?? current['content']; + const replyTo = changes.reply_to ?? emailFrom(current['reply_to']); + if (typeof subject === 'string') merged['subject'] = subject; + if (typeof content === 'string') merged['content'] = content; + if (replyTo) merged['reply_to'] = replyTo; + if (current['schedule_details']) merged['schedule_details'] = current['schedule_details']; + if (Array.isArray(current['attachments']) && current['attachments'].length > 0) { + merged['attachments'] = current['attachments']; + } + if (!merged['from']) throw new ZohoCrmError('The draft has no from address; pass from.'); + return merged; +} + +const richTextProp = (forUpdate: boolean) => + Property.StaticDropdown({ + displayName: 'Content Format', + description: forUpdate ? 'Leave empty to keep the current format.' : 'HTML (rich text) or plain text. Default HTML.', + required: false, + options: { options: [{ label: 'HTML', value: 'true' }, { label: 'Plain text', value: 'false' }] }, + }); + +function richText(value: unknown): boolean | undefined { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return undefined; +} + +export const createEmailDraftAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_email_draft', + classification: 'WRITE', + displayName: 'Create Email Draft', + description: 'Saves an email draft on a record without sending it.', + audience: 'ai', + aiMetadata: { + description: + 'Saves an email draft (from, to, cc, bcc, subject, body) in the Emails related list of one Zoho CRM record, without sending it; a person can review and send it from the CRM. The from address must be one the connected user may send from (their own email, see Get Current User). Not idempotent: each call saves another draft.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + from: Property.ShortText({ displayName: 'From', description: 'Sender email, e.g. the connected user\'s email.', required: true }), + to: Property.Array({ displayName: 'To', description: 'Recipient emails.', required: false }), + cc: Property.Array({ displayName: 'Cc', required: false }), + bcc: Property.Array({ displayName: 'Bcc', required: false }), + subject: Property.ShortText({ displayName: 'Subject', required: false }), + content: Property.LongText({ displayName: 'Body', required: false }), + rich_text: richTextProp(false), + reply_to: Property.ShortText({ displayName: 'Reply To', required: false }), + }, + outputSchema: draftWriteOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const to = recipients({ value: p.to, name: 'to' }); + const cc = recipients({ value: p.cc, name: 'cc' }); + const bcc = recipients({ value: p.bcc, name: 'bcc' }); + const draft: Record = { + from: email({ value: p.from, name: 'from' }), + rich_text: richText(p.rich_text) ?? true, + ...(to && to.length > 0 ? { to } : {}), + ...(cc && cc.length > 0 ? { cc } : {}), + ...(bcc && bcc.length > 0 ? { bcc } : {}), + ...(p.subject ? { subject: p.subject } : {}), + ...(p.content ? { content: p.content } : {}), + ...(p.reply_to ? { reply_to: email({ value: p.reply_to, name: 'reply_to' }) } : {}), + }; + const body = await zohoRequest({ auth, method: HttpMethod.POST, path: draftPath({ module, id }), body: { __email_drafts: [draft] } }); + const item = unwrapWriteItem({ body, key: '__email_drafts' }); + return { id: readString(item.details?.['id']) ?? null, module, record_id: id, status: item.status ?? null, message: item.message ?? null }; + }, +}); + +export const listEmailDraftsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_email_drafts', + classification: 'SEARCH', + displayName: 'List Email Drafts', + description: 'Lists the email drafts saved on a record.', + audience: 'ai', + aiMetadata: { + description: + 'Lists email drafts saved on one Zoho CRM record (at most 10 per page), or returns one draft when draft_id is given. Use to find a draft id before Update Email Draft. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + draft_id: Property.ShortText({ displayName: 'Draft ID', description: 'Optional: return only this draft.', required: false }), + page: Property.Number({ displayName: 'Page', required: false }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 10 (Zoho maximum 10).', required: false }), + }, + outputSchema: listDraftsOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const draft = p.draft_id ? requireKey({ value: p.draft_id, name: 'draft_id' }) : undefined; + const page = optionalInt({ value: p.page, name: 'page', min: 1, max: 2000 }); + const perPage = optionalInt({ value: p.per_page, name: 'per_page', min: 1, max: 10 }); + validatePaging({ page, perPage: perPage ?? 10, tokenSupported: false }); + const body = await zohoRequest<{ __email_drafts?: Record[]; info?: { more_records?: boolean } }>({ + auth, + method: HttpMethod.GET, + path: draft ? `${draftPath({ module, id })}/${draft}` : draftPath({ module, id }), + query: draft ? undefined : { ...(page ? { page: String(page) } : {}), ...(perPage ? { per_page: String(perPage) } : {}) }, + }); + const drafts = (body?.__email_drafts ?? []).map(flattenDraft); + return { drafts, count: drafts.length, more_records: body?.info?.more_records === true }; + }, +}); + +export const updateEmailDraftAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_update_email_draft', + classification: 'WRITE', + displayName: 'Update Email Draft', + description: 'Changes an email draft; fields you leave empty keep their value.', + audience: 'ai', + aiMetadata: { + description: + 'Changes an existing email draft on a Zoho CRM record without sending it; it reads the draft first and keeps every field you do not pass (recipients, subject, body, attachments, schedule). A recipient list you pass replaces that list; Zoho cannot empty a list, so an empty list is refused. Idempotent: the same changes give the same draft.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + draft_id: draftId, + from: Property.ShortText({ displayName: 'From', required: false }), + to: Property.Array({ displayName: 'To', description: 'Replaces the To list; omit it to keep the current list.', required: false }), + cc: Property.Array({ displayName: 'Cc', description: 'Replaces the Cc list; omit it to keep the current list.', required: false }), + bcc: Property.Array({ displayName: 'Bcc', description: 'Replaces the Bcc list; omit it to keep the current list.', required: false }), + subject: Property.ShortText({ displayName: 'Subject', required: false }), + content: Property.LongText({ displayName: 'Body', required: false }), + rich_text: richTextProp(true), + reply_to: Property.ShortText({ displayName: 'Reply To', required: false }), + }, + outputSchema: draftWriteOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const draft = requireKey({ value: p.draft_id, name: 'draft_id' }); + const changes: DraftChanges = { + from: p.from ? email({ value: p.from, name: 'from' }) : undefined, + to: replacementRecipients({ value: p.to, name: 'to' }), + cc: replacementRecipients({ value: p.cc, name: 'cc' }), + bcc: replacementRecipients({ value: p.bcc, name: 'bcc' }), + subject: p.subject || undefined, + content: p.content || undefined, + rich_text: richText(p.rich_text), + reply_to: p.reply_to ? email({ value: p.reply_to, name: 'reply_to' }) : undefined, + }; + const current = await fetchDraft({ auth, module, id, draft }); + const merged = mergeDraft({ current, changes, draft }); + const body = await zohoRequest({ auth, method: HttpMethod.PUT, path: draftPath({ module, id }), body: { __email_drafts: [merged] } }); + const item = unwrapWriteItem({ body, key: '__email_drafts' }); + return { id: readString(item.details?.['id']) ?? draft, module, record_id: id, status: item.status ?? null, message: item.message ?? null }; + }, +}); + +type DraftChanges = { + from?: string; + to?: { email: string }[]; + cc?: { email: string }[]; + bcc?: { email: string }[]; + subject?: string; + content?: string; + rich_text?: boolean; + reply_to?: string; +}; diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/index.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/index.ts new file mode 100644 index 000000000000..3005db3267e9 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/index.ts @@ -0,0 +1,73 @@ +import { + getCurrentUserAtomic, + getModuleFieldsAtomic, + getRelatedListsAtomic, + getUserAtomic, + listModulesAtomic, + listUsersAtomic, +} from './metadata'; +import { + createRecordAtomic, + deleteRecordAtomic, + getRecordAtomic, + listRecordsAtomic, + updateRecordAtomic, + upsertRecordAtomic, +} from './records'; +import { convertLeadAtomic, createContactAtomic, createDealAtomic, createEventAtomic, createLeadAtomic } from './typed'; +import { + addTagsAtomic, + createNoteAtomic, + createTagAtomic, + deleteNoteAtomic, + getNoteAtomic, + listNotesAtomic, + listTagsAtomic, + removeTagsAtomic, + updateNoteAtomic, +} from './notes-tags'; +import { + downloadAttachmentAtomic, + listRelatedRecordsAtomic, + linkRelatedRecordAtomic, + listAttachmentsAtomic, + uploadAttachmentAtomic, +} from './related-attachments'; +import { createEmailDraftAtomic, listEmailDraftsAtomic, updateEmailDraftAtomic } from './drafts'; + +export const zohoAiActions = [ + listModulesAtomic, + getModuleFieldsAtomic, + getRelatedListsAtomic, + listUsersAtomic, + getUserAtomic, + getCurrentUserAtomic, + listRecordsAtomic, + getRecordAtomic, + createRecordAtomic, + updateRecordAtomic, + deleteRecordAtomic, + upsertRecordAtomic, + createLeadAtomic, + createContactAtomic, + createDealAtomic, + createEventAtomic, + convertLeadAtomic, + listNotesAtomic, + getNoteAtomic, + createNoteAtomic, + updateNoteAtomic, + deleteNoteAtomic, + listTagsAtomic, + createTagAtomic, + addTagsAtomic, + removeTagsAtomic, + listRelatedRecordsAtomic, + linkRelatedRecordAtomic, + listAttachmentsAtomic, + uploadAttachmentAtomic, + downloadAttachmentAtomic, + createEmailDraftAtomic, + listEmailDraftsAtomic, + updateEmailDraftAtomic, +]; diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/metadata.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/metadata.ts new file mode 100644 index 000000000000..aaf02be5f4e7 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/metadata.ts @@ -0,0 +1,237 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { ZohoCrmError, optionalInt, requireApiName, requireId, validatePaging, zohoRequest } from '../../common/client'; +import { flattenRelatedList, flattenUser } from '../../common/flatten'; +import { isApiReadOnly } from '../../common/fields'; +import { listFields, listModules } from '../../common/metadata'; +import { + listModulesOutputSchema, + listUsersOutputSchema, + moduleFieldsOutputSchema, + relatedListsOutputSchema, + userOutputSchema, +} from '../../output-schemas-ai'; + +const moduleApiName = Property.ShortText({ + displayName: 'Module API Name', + description: 'Module API name from List Modules, e.g. "Leads", "Contacts", "Deals", "Accounts" or "CustomModule1". Not the display label.', + required: true, +}); + +export const listModulesAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_modules', + classification: 'SEARCH', + displayName: 'List Modules', + description: 'Lists the modules available through the API.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the Zoho CRM modules the API can use, with each module\'s API name, labels and whether records can be created, edited or deleted. Call this first to get the module_api_name every record, note, tag and attachment action needs; the API name can differ from the label (e.g. "Deals" labelled "Opportunities", custom modules named "CustomModule1"). Read-only and idempotent.', + idempotent: true, + }, + props: {}, + outputSchema: listModulesOutputSchema, + async run({ auth }) { + const modules = (await listModules(auth)) + .filter((m) => m.api_supported !== false) + .map((m) => ({ + api_name: m.api_name, + module_name: m.module_name ?? null, + plural_label: m.plural_label ?? null, + singular_label: m.singular_label ?? null, + generated_type: m.generated_type ?? null, + creatable: m.creatable ?? null, + editable: m.editable ?? null, + deletable: m.deletable ?? null, + })); + return { modules, count: modules.length }; + }, +}); + +export const getModuleFieldsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_module_fields', + classification: 'SEARCH', + displayName: 'Get Module Fields', + description: 'Lists the fields of a module, custom fields included.', + audience: 'ai', + aiMetadata: { + description: + 'Lists every field of one Zoho CRM module with its API name, label, data type, whether it is required on create, read-only status, picklist values and lookup target. Call before creating or updating records to learn field API names, allowed picklist values (e.g. Deal Stage) and custom fields. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + include_read_only: Property.Checkbox({ + displayName: 'Include Read-Only Fields', + description: 'Also list fields that cannot be written (formula, system, auto-number).', + required: false, + defaultValue: true, + }), + }, + outputSchema: moduleFieldsOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }); + const all = await listFields({ auth, module: module }); + const fields = all + .filter((f) => f.visible !== false) + .filter((f) => propsValue.include_read_only !== false || !isApiReadOnly(f)) + .map((f) => ({ + api_name: f.api_name, + field_label: f.display_label ?? f.field_label ?? f.api_name, + data_type: f.data_type ?? null, + required: f.system_mandatory === true, + read_only: isApiReadOnly(f), + custom_field: f.custom_field === true, + lookup_module: f.lookup?.module?.api_name ?? null, + max_length: f.length ?? null, + picklist_values: (f.pick_list_values ?? []) + .map((p) => p.actual_value) + .filter((v): v is string => typeof v === 'string' && v !== '-None-'), + })); + return { module, fields, count: fields.length }; + }, +}); + +export const getRelatedListsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_related_lists', + classification: 'SEARCH', + displayName: 'Get Related Lists', + description: 'Lists the related lists of a module.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the related lists of one Zoho CRM module (e.g. Contacts, Deals or Campaigns under Accounts) with the related_list API name and the related module. Call before List Related Records or Link Related Record to get the related_list_api_name. Read-only and idempotent.', + idempotent: true, + }, + props: { module_api_name: moduleApiName }, + outputSchema: relatedListsOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }); + const body = await zohoRequest<{ related_lists?: Record[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/related_lists', + query: { module }, + }); + const related_lists = (body?.related_lists ?? []).map(flattenRelatedList); + return { module, related_lists, count: related_lists.length }; + }, +}); + +const USER_TYPES = [ + 'AllUsers', + 'ActiveUsers', + 'DeactiveUsers', + 'ConfirmedUsers', + 'NotConfirmedUsers', + 'DeletedUsers', + 'ActiveConfirmedUsers', + 'AdminUsers', + 'ActiveConfirmedAdmins', +]; + +export const listUsersAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_users', + classification: 'SEARCH', + displayName: 'List Users', + description: 'Lists CRM users.', + audience: 'ai', + aiMetadata: { + description: + 'Lists users of the Zoho CRM org, filtered by type (active users by default), one page of up to 200 (page numbers reach the first 2,000 users). Use to find a user id for a record Owner or a lead conversion assignee; use Get Current User for the connected user. Read-only and idempotent.', + idempotent: true, + }, + props: { + type: Property.StaticDropdown({ + displayName: 'User Type', + required: false, + defaultValue: 'ActiveUsers', + options: { options: USER_TYPES.map((t) => ({ label: t, value: t })) }, + }), + page: Property.Number({ displayName: 'Page', description: 'Page number, starting at 1.', required: false }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 200 (default 200).', required: false }), + }, + outputSchema: listUsersOutputSchema, + async run({ auth, propsValue }) { + const type = propsValue.type ?? 'ActiveUsers'; + if (!USER_TYPES.includes(type)) { + throw new ZohoCrmError(`type must be one of ${USER_TYPES.join(', ')}.`); + } + const page = optionalInt({ value: propsValue.page, name: 'page', min: 1, max: 2000 }); + const perPage = optionalInt({ value: propsValue.per_page, name: 'per_page', min: 1, max: 200 }); + validatePaging({ page, perPage: perPage ?? 200, tokenSupported: false }); + const body = await zohoRequest<{ users?: Record[]; info?: { more_records?: boolean } }>({ + auth, + method: HttpMethod.GET, + path: '/users', + query: { + type, + ...(page ? { page: String(page) } : {}), + ...(perPage ? { per_page: String(perPage) } : {}), + }, + }); + const users = (body?.users ?? []).map(flattenUser); + return { users, count: users.length, more_records: body?.info?.more_records === true }; + }, +}); + +export const getUserAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_user', + classification: 'READ', + displayName: 'Get User', + description: 'Gets a CRM user by id.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one Zoho CRM user by id with name, email, role, profile and status. Use to resolve an Owner id seen on a record; to find users by listing use List Users. Read-only and idempotent.', + idempotent: true, + }, + props: { + user_id: Property.ShortText({ displayName: 'User ID', description: 'Numeric user id, e.g. "5725767000000411001".', required: true }), + }, + outputSchema: userOutputSchema, + async run({ auth, propsValue }) { + const id = requireId({ value: propsValue.user_id, name: 'user_id' }); + const body = await zohoRequest<{ users?: Record[] }>({ auth, method: HttpMethod.GET, path: `/users/${id}` }); + const user = body?.users?.[0]; + if (!user) { + throw new ZohoCrmError(`No user with id ${id} was found.`, 404, 'NOT_FOUND'); + } + return flattenUser(user); + }, +}); + +export const getCurrentUserAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Gets the connected CRM user.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the Zoho CRM user this connection acts as (id, name, email, role, profile). Use to get the default Owner id or the "from" address for an email draft. Read-only and idempotent.', + idempotent: true, + }, + props: {}, + outputSchema: userOutputSchema, + async run({ auth }) { + const body = await zohoRequest<{ users?: Record[] }>({ + auth, + method: HttpMethod.GET, + path: '/users', + query: { type: 'CurrentUser' }, + }); + const user = body?.users?.[0]; + if (!user) { + throw new ZohoCrmError('Zoho did not return the current user.'); + } + return flattenUser(user); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/notes-tags.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/notes-tags.ts new file mode 100644 index 000000000000..4e7bf25ecee6 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/notes-tags.ts @@ -0,0 +1,299 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { + ZohoCrmError, + ZohoListResponse, + flattenWriteResult, + optionalInt, + requireApiName, + requireId, + stringList, + unwrapWriteItem, + zohoRequest, + validatePaging, +} from '../../common/client'; +import { flattenNote } from '../../common/flatten'; +import { TAG_COLORS, changeTags, createNote, ensureTag, listTags, notDeletedError } from '../../common/records'; +import { + createTagOutputSchema, + listNotesOutputSchema, + listTagsOutputSchema, + noteChangeOutputSchema, + noteOutputSchema, + noteWriteOutputSchema, +} from '../../output-schemas-ai'; +import { tagChangeOutputSchema } from '../../output-schemas'; + +const NOTE_FIELDS = 'Note_Title,Note_Content,Parent_Id,Owner,Created_Time,Modified_Time'; + +const moduleApiName = Property.ShortText({ + displayName: 'Module API Name', + description: 'Module API name from List Modules, e.g. "Leads" or "Deals".', + required: true, +}); + +const noteId = Property.ShortText({ displayName: 'Note ID', description: 'Numeric note id from List Notes.', required: true }); + +export const listNotesAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_notes', + classification: 'SEARCH', + displayName: 'List Notes', + description: 'Lists notes of a record, or all notes.', + audience: 'ai', + aiMetadata: { + description: + 'Lists notes attached to one Zoho CRM record (give parent_module and parent_id), or all notes in the org when both are empty (admin users only), one page of up to 200; page numbers reach the first 2,000 notes. Read-only and idempotent.', + idempotent: true, + }, + props: { + parent_module: Property.ShortText({ displayName: 'Parent Module', description: 'Module API name of the record, e.g. "Leads". Give with parent_id.', required: false }), + parent_id: Property.ShortText({ displayName: 'Parent Record ID', required: false }), + page: Property.Number({ displayName: 'Page', required: false }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 200 (default 50).', required: false }), + }, + outputSchema: listNotesOutputSchema, + async run({ auth, propsValue: p }) { + if (!p.parent_module !== !p.parent_id) { + throw new ZohoCrmError('Give parent_module and parent_id together, or neither.'); + } + const path = p.parent_module + ? `/${encodeURIComponent(requireApiName({ value: p.parent_module, name: 'parent_module' }))}/${requireId({ value: p.parent_id, name: 'parent_id' })}/Notes` + : '/Notes'; + const page = optionalInt({ value: p.page, name: 'page', min: 1, max: 2000 }); + const perPage = optionalInt({ value: p.per_page, name: 'per_page', min: 1, max: 200 }) ?? 50; + validatePaging({ page, perPage, tokenSupported: false }); + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path, + query: { + fields: NOTE_FIELDS, + per_page: String(perPage), + ...(page ? { page: String(page) } : {}), + }, + }); + const notes = (body?.data ?? []).map(flattenNote); + return { notes, count: notes.length, more_records: body?.info?.more_records === true }; + }, +}); + +export const getNoteAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_note', + classification: 'READ', + displayName: 'Get Note', + description: 'Gets a note by id.', + audience: 'ai', + aiMetadata: { + description: 'Fetches one Zoho CRM note by id with its title, content, parent record and owner. Read-only and idempotent.', + idempotent: true, + }, + props: { note_id: noteId }, + outputSchema: noteOutputSchema, + async run({ auth, propsValue }) { + const id = requireId({ value: propsValue.note_id, name: 'note_id' }); + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/Notes/${id}`, + query: { fields: NOTE_FIELDS }, + }); + const note = body?.data?.[0]; + if (!note) throw new ZohoCrmError(`No note with id ${id} was found.`, 404, 'NOT_FOUND'); + return flattenNote(note); + }, +}); + +export const createNoteAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_note', + classification: 'WRITE', + displayName: 'Create Note', + description: 'Adds a note to a record.', + audience: 'ai', + aiMetadata: { + description: + 'Adds a note (content required, title optional) to one Zoho CRM record identified by module API name and record id. Use to log context such as a call summary. Not idempotent: each call adds another note.', + idempotent: false, + }, + props: { + parent_module: moduleApiName, + parent_id: Property.ShortText({ displayName: 'Parent Record ID', required: true }), + title: Property.ShortText({ displayName: 'Title', required: false }), + content: Property.LongText({ displayName: 'Content', required: true }), + }, + outputSchema: noteWriteOutputSchema, + async run({ auth, propsValue: p }) { + return createNote({ + auth, + module: requireApiName({ value: p.parent_module, name: 'parent_module' }), + recordId: requireId({ value: p.parent_id, name: 'parent_id' }), + title: p.title, + content: p.content, + }); + }, +}); + +export const updateNoteAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_update_note', + classification: 'WRITE', + displayName: 'Update Note', + description: 'Changes the title or content of a note.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the title and/or content of one Zoho CRM note; a field you leave out keeps its value (only title and content can change). Idempotent: re-sending the same text converges on the same note.', + idempotent: true, + }, + props: { + note_id: noteId, + title: Property.ShortText({ displayName: 'Title', required: false }), + content: Property.LongText({ displayName: 'Content', required: false }), + }, + outputSchema: noteChangeOutputSchema, + async run({ auth, propsValue: p }) { + const id = requireId({ value: p.note_id, name: 'note_id' }); + const data: Record = {}; + if (p.title !== undefined && p.title !== null && p.title !== '') data['Note_Title'] = p.title; + if (p.content !== undefined && p.content !== null && p.content !== '') data['Note_Content'] = p.content; + if (Object.keys(data).length === 0) { + throw new ZohoCrmError('Give a new title or content.'); + } + const body = await zohoRequest({ auth, method: HttpMethod.PUT, path: `/Notes/${id}`, body: { data: [data] } }); + return { ...flattenWriteResult(unwrapWriteItem({ body })), id }; + }, +}); + +export const deleteNoteAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_delete_note', + classification: 'DESTRUCTIVE', + displayName: 'Delete Note', + description: 'Deletes a note.', + audience: 'ai', + aiMetadata: { + description: 'Deletes one Zoho CRM note by id. Only use when asked to remove the note. Not idempotent: deleting it again returns an error.', + idempotent: false, + }, + props: { note_id: noteId }, + outputSchema: noteChangeOutputSchema, + async run({ auth, propsValue }) { + const id = requireId({ value: propsValue.note_id, name: 'note_id' }); + let body: unknown; + try { + body = await zohoRequest({ auth, method: HttpMethod.DELETE, path: `/Notes/${id}` }); + } catch (error) { + throw notDeletedError({ error, what: `note ${id}` }); + } + return { ...flattenWriteResult(unwrapWriteItem({ body })), id }; + }, +}); + +export const listTagsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_tags', + classification: 'SEARCH', + displayName: 'List Tags', + description: 'Lists the tags of a module.', + audience: 'ai', + aiMetadata: { + description: 'Lists the tags defined for one Zoho CRM module (name, id, color). Use to check tag names before Add Tags or Remove Tags. Read-only and idempotent.', + idempotent: true, + }, + props: { module_api_name: moduleApiName }, + outputSchema: listTagsOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }); + const tags = (await listTags({ auth, module: module })).map((t) => ({ + id: t.id, + name: t.name, + color_code: t.color_code ?? null, + created_time: t.created_time ?? null, + modified_time: t.modified_time ?? null, + })); + return { module, tags, count: tags.length }; + }, +}); + +export const createTagAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_tag', + classification: 'WRITE', + displayName: 'Create Tag', + description: 'Creates a tag in a module, or returns it if it exists.', + audience: 'ai', + aiMetadata: { + description: + 'Gets or creates a tag by name (case-insensitive) in one Zoho CRM module, returning created false when it already existed. Use before Add Tags when the tag may be new; a module holds at most 100 tags. Idempotent: repeating returns the same tag.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + name: Property.ShortText({ displayName: 'Tag Name', required: true }), + color_code: Property.StaticDropdown({ + displayName: 'Color', + required: false, + options: { options: TAG_COLORS.map((c) => ({ label: c, value: c })) }, + }), + }, + outputSchema: createTagOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const { tag, created } = await ensureTag({ auth, module, name: p.name, colorCode: p.color_code }); + return { id: tag.id, name: tag.name, color_code: tag.color_code ?? null, module, created }; + }, +}); + +const tagNames = Property.Array({ displayName: 'Tag Names', description: 'Tag names, e.g. ["VIP", "Webinar 2026"].', required: true }); +const recordIds = Property.Array({ displayName: 'Record IDs', description: 'Numeric record ids (max 500).', required: true }); + +function tagInputs(p: { module_api_name: unknown; record_ids: unknown; tags: unknown }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const ids = stringList(p.record_ids).map((id) => requireId({ value: id, name: 'record_ids' })); + const names = stringList(p.tags); + if (names.length > 10) throw new ZohoCrmError('Zoho allows at most 10 tags per record.'); + return { module, ids, tags: names.map((name) => ({ name })) }; +} + +export const addTagsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_add_tags', + classification: 'WRITE', + displayName: 'Add Tags', + description: 'Adds tags to one or more records.', + audience: 'ai', + aiMetadata: { + description: + 'Adds tags by name to up to 500 records of one Zoho CRM module, keeping tags the records already have (max 10 per record). A tag name the module does not have yet is created by Zoho on the fly; use Create Tag first only to pick its color. Idempotent: re-adding an existing tag changes nothing.', + idempotent: true, + }, + props: { module_api_name: moduleApiName, record_ids: recordIds, tags: tagNames }, + outputSchema: tagChangeOutputSchema, + async run({ auth, propsValue }) { + const { module, ids, tags } = tagInputs(propsValue); + return changeTags({ auth, module, recordIds: ids, tags, mode: 'add' }); + }, +}); + +export const removeTagsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_remove_tags', + classification: 'WRITE', + displayName: 'Remove Tags', + description: 'Removes tags from one or more records.', + audience: 'ai', + aiMetadata: { + description: + 'Removes tags by name from up to 500 records of one Zoho CRM module; the tags themselves stay defined in the module. Idempotent: removing a tag that is not on a record changes nothing.', + idempotent: true, + }, + props: { module_api_name: moduleApiName, record_ids: recordIds, tags: tagNames }, + outputSchema: tagChangeOutputSchema, + async run({ auth, propsValue }) { + const { module, ids, tags } = tagInputs(propsValue); + return changeTags({ auth, module, recordIds: ids, tags, mode: 'remove' }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/records.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/records.ts new file mode 100644 index 000000000000..c661d5c1ba2a --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/records.ts @@ -0,0 +1,287 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { + ZohoCrmError, + ZohoListResponse, + optionalId, + optionalInt, + parseJsonObject, + parseTriggers, + requireApiName, + requireId, + stringList, + validatePaging, + zohoRequest, +} from '../../common/client'; +import { defaultFieldSelection, listFields } from '../../common/metadata'; +import { createRecord, deleteRecord, getRecord, updateRecord, upsertRecord } from '../../common/records'; +import { + aiUpsertOutputSchema, + aiWriteOutputSchema, + listRecordsOutputSchema, +} from '../../output-schemas-ai'; +import { deleteOutputSchema, recordOutputSchema } from '../../output-schemas'; + +const moduleApiName = Property.ShortText({ + displayName: 'Module API Name', + description: 'Module API name from List Modules, e.g. "Leads", "Contacts", "Deals" or "CustomModule1".', + required: true, +}); + +const recordId = Property.ShortText({ + displayName: 'Record ID', + description: 'Numeric Zoho record id, e.g. "5725767000000524157" (from List Records or a create result).', + required: true, +}); + +const dataProp = (required: boolean) => + Property.Json({ + displayName: 'Data', + description: + 'JSON object of field API names to values, e.g. {"Last_Name": "Doe", "Email": "jane@example.com", "Owner": {"id": "5725767000000411001"}}. Get API names and picklist values from Get Module Fields. Dates as "2026-10-01", date-times as "2026-10-01T09:00:00+02:00", lookups as {"id": "..."}.', + required, + }); + +const skipAutomation = Property.Checkbox({ + displayName: 'Skip Automation', + description: 'When true, no workflow, approval or blueprint runs for this change. Default false (Zoho runs its automation).', + required: false, + defaultValue: false, +}); + +export const listRecordsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_records', + classification: 'SEARCH', + displayName: 'List Records', + description: 'Lists records of a module, one page at a time.', + audience: 'ai', + aiMetadata: { + description: + 'Lists records of one Zoho CRM module one page at a time (up to 200), sorted by id, Created_Time or Modified_Time, optionally within a custom view. Use to browse or page through a module; it does not filter by field value, so use Get Record when you know the id. Returns at most 50 fields per record (default: id, then custom fields, then standard fields, up to 50). Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + fields: Property.Array({ + displayName: 'Fields', + description: 'Field API names to return (max 50). Leave empty for id, then the custom fields of the module, then its standard fields (50 in total).', + required: false, + }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 200. Default 50.', required: false }), + page: Property.Number({ displayName: 'Page', description: 'Page number for the first 2,000 records. Do not combine with page_token.', required: false }), + page_token: Property.ShortText({ displayName: 'Page Token', description: 'next_page_token from a previous call, needed beyond 2,000 records.', required: false }), + sort_by: Property.StaticDropdown({ + displayName: 'Sort By', + required: false, + options: { options: ['id', 'Created_Time', 'Modified_Time'].map((v) => ({ label: v, value: v })) }, + }), + sort_order: Property.StaticDropdown({ + displayName: 'Sort Order', + required: false, + options: { options: [{ label: 'Descending', value: 'desc' }, { label: 'Ascending', value: 'asc' }] }, + }), + custom_view_id: Property.ShortText({ displayName: 'Custom View ID', description: 'Optional id of a custom view (cvid) to list only its records.', required: false }), + }, + outputSchema: listRecordsOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }); + const perPage = optionalInt({ value: propsValue.per_page, name: 'per_page', min: 1, max: 200 }) ?? 50; + const page = optionalInt({ value: propsValue.page, name: 'page', min: 1, max: 2000 }); + const pageToken = propsValue.page_token?.trim() || undefined; + validatePaging({ page, perPage, pageToken, tokenSupported: true }); + const cvid = optionalId({ value: propsValue.custom_view_id, name: 'custom_view_id' }); + let fields = stringList(propsValue.fields).map((f) => requireApiName({ value: f, name: 'fields' })); + if (fields.length > 50) { + throw new ZohoCrmError('Zoho returns at most 50 fields per call.'); + } + if (fields.length === 0) { + fields = defaultFieldSelection({ fields: await listFields({ auth, module: module }) }); + } + const sortBy = propsValue.sort_by; + const sortOrder = propsValue.sort_order; + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}`, + query: { + fields: fields.join(','), + per_page: String(perPage), + ...(page ? { page: String(page) } : {}), + ...(pageToken ? { page_token: pageToken } : {}), + ...(sortBy ? { sort_by: sortBy } : {}), + ...(sortOrder ? { sort_order: sortOrder } : {}), + ...(cvid ? { cvid } : {}), + }, + }); + const records = body?.data ?? []; + return { + module, + records, + count: records.length, + more_records: body?.info?.more_records === true, + page: body?.info?.page ?? page ?? null, + next_page_token: body?.info?.next_page_token ?? null, + fields_requested: fields.join(','), + }; + }, +}); + +export const getRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_get_record', + classification: 'READ', + displayName: 'Get Record', + description: 'Gets one record by id.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches one Zoho CRM record by module API name and record id, with all fields (keys are field API names, custom fields included) unless a field list is given. Use when you hold a record id; use List Records to browse. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + fields: Property.Array({ displayName: 'Fields', description: 'Optional field API names to return; empty returns all fields.', required: false }), + }, + outputSchema: recordOutputSchema, + async run({ auth, propsValue }) { + return getRecord({ + auth, + module: requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }), + id: requireId({ value: propsValue.record_id, name: 'record_id' }), + fields: stringList(propsValue.fields).map((f) => requireApiName({ value: f, name: 'fields' })), + }); + }, +}); + +export const createRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_record', + classification: 'WRITE', + displayName: 'Create Record', + description: 'Creates a record in any module from a JSON object of fields.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one record in any Zoho CRM module (standard or custom) from a JSON object of field API names; prefer Create Lead, Create Contact, Create Deal or Create Event for those modules, and Upsert Record when the record may already exist. Resolve field API names and required fields with Get Module Fields first. Not idempotent: each call creates a new record.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + data: dataProp(true), + skip_automation: skipAutomation, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue }) { + return createRecord({ + auth, + module: requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }), + record: parseJsonObject({ value: propsValue.data, name: 'data' }), + trigger: parseTriggers({ value: undefined, skipAll: propsValue.skip_automation }), + }); + }, +}); + +export const updateRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_update_record', + classification: 'WRITE', + displayName: 'Update Record', + description: 'Updates the given fields of a record; other fields are unchanged.', + audience: 'ai', + aiMetadata: { + description: + 'Updates only the fields present in data on one Zoho CRM record; fields you omit keep their values and a field set to null is cleared. Multi-select picklists are replaced unless listed in append_multiselect_fields. Not idempotent: every call counts as an edit and runs the module\'s on-edit workflows unless automation is skipped.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + data: dataProp(true), + append_multiselect_fields: Property.Array({ + displayName: 'Append Multi-Select Fields', + description: 'Multi-select picklist API names whose new values should be added to the current ones instead of replacing them.', + required: false, + }), + skip_automation: skipAutomation, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue }) { + const record = parseJsonObject({ value: propsValue.data, name: 'data' }); + if ('id' in record) { + throw new ZohoCrmError('Do not put "id" in data; pass it as record_id.'); + } + return updateRecord({ + auth, + module: requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }), + id: requireId({ value: propsValue.record_id, name: 'record_id' }), + record, + appendMultiSelect: stringList(propsValue.append_multiselect_fields).map((f) => requireApiName({ value: f, name: 'append_multiselect_fields' })), + trigger: parseTriggers({ value: undefined, skipAll: propsValue.skip_automation }), + }); + }, +}); + +export const deleteRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_delete_record', + classification: 'DESTRUCTIVE', + displayName: 'Delete Record', + description: 'Deletes a record (moves it to the Recycle Bin).', + audience: 'ai', + aiMetadata: { + description: + 'Deletes one Zoho CRM record by module and id; it moves to the Recycle Bin, restorable only from the Zoho UI. Only use when the user asked to delete; to change data use Update Record. Not idempotent: a second call on the same id fails.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + run_workflows: Property.Checkbox({ displayName: 'Run Workflows', description: 'Default true.', required: false, defaultValue: true }), + }, + outputSchema: deleteOutputSchema, + async run({ auth, propsValue }) { + return deleteRecord({ + auth, + module: requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }), + id: requireId({ value: propsValue.record_id, name: 'record_id' }), + runWorkflows: propsValue.run_workflows !== false, + }); + }, +}); + +export const upsertRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_upsert_record', + classification: 'WRITE', + displayName: 'Upsert Record', + description: 'Updates the matching record or creates it.', + audience: 'ai', + aiMetadata: { + description: + 'Inserts or updates one Zoho CRM record, matching on duplicate_check_fields (Zoho default: Email for Leads/Contacts, Account_Name for Accounts, Deal_Name for Deals, Name for custom modules); the result says action insert or update. Use as find-or-create. Not idempotent: when the data has no value for a duplicate-check field, every call inserts a new record.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + data: dataProp(true), + duplicate_check_fields: Property.Array({ + displayName: 'Duplicate Check Fields', + description: 'Field API names to match on, e.g. ["Email"]. Empty uses the module default.', + required: false, + }), + skip_automation: skipAutomation, + }, + outputSchema: aiUpsertOutputSchema, + async run({ auth, propsValue }) { + return upsertRecord({ + auth, + module: requireApiName({ value: propsValue.module_api_name, name: 'module_api_name' }), + record: parseJsonObject({ value: propsValue.data, name: 'data' }), + duplicateCheckFields: stringList(propsValue.duplicate_check_fields).map((f) => requireApiName({ value: f, name: 'duplicate_check_fields' })), + trigger: parseTriggers({ value: undefined, skipAll: propsValue.skip_automation }), + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/related-attachments.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/related-attachments.ts new file mode 100644 index 000000000000..7204969618d9 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/related-attachments.ts @@ -0,0 +1,259 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { + ZohoAuth, + ZohoCrmError, + ZohoListResponse, + optionalInt, + parseJsonObject, + requireApiName, + requireId, + stringList, + unwrapWriteItem, + zohoRequest, + zohoApiUrl, + validatePaging, +} from '../../common/client'; +import { downloadToFile } from '../../common/download'; +import { flattenAttachment } from '../../common/flatten'; +import { defaultFieldSelection } from '../../common/metadata'; +import { tryListFields, uploadAttachment } from '../../common/records'; +import { + downloadAttachmentOutputSchema, + linkRelatedOutputSchema, + listAttachmentsOutputSchema, + relatedRecordsOutputSchema, +} from '../../output-schemas-ai'; +import { childWriteOutputSchema } from '../../output-schemas'; + +const ATTACHMENT_FIELDS = 'id,File_Name,Size,Created_Time,Modified_Time,Owner,Created_By,$file_id,$se_module,$type,$link_url'; + +const moduleApiName = Property.ShortText({ + displayName: 'Module API Name', + description: 'Module API name of the parent record, e.g. "Accounts" or "Leads".', + required: true, +}); +const recordId = Property.ShortText({ displayName: 'Record ID', description: 'Numeric id of the parent record.', required: true }); +const relatedList = Property.ShortText({ + displayName: 'Related List API Name', + description: 'api_name from Get Related Lists, e.g. "Contacts", "Deals", "Campaigns", "Products".', + required: true, +}); + +async function relatedModuleFields({ auth, module, related }: { auth: ZohoAuth; module: string; related: string }): Promise { + const body = await zohoRequest<{ related_lists?: { api_name?: string; module?: { api_name?: string } }[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/related_lists', + query: { module }, + }); + const target = body?.related_lists?.find((r) => r.api_name === related)?.module?.api_name; + if (!target) return ['id']; + const fields = await tryListFields({ auth, module: target }); + return fields ? defaultFieldSelection({ fields }) : ['id']; +} + +export const listRelatedRecordsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_related_records', + classification: 'SEARCH', + displayName: 'List Related Records', + description: 'Lists records in a related list of a record.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the records in one related list of a Zoho CRM record, e.g. the Contacts or Deals of an Account, one page of up to 200 (at most 50 fields per record; default id, then custom fields, then standard fields of the related module). Get related_list_api_name from Get Related Lists. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + related_list_api_name: relatedList, + fields: Property.Array({ displayName: 'Fields', description: 'Field API names to return (max 50).', required: false }), + page: Property.Number({ displayName: 'Page', required: false }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 200 (default 50).', required: false }), + page_token: Property.ShortText({ displayName: 'Page Token', description: 'next_page_token from a previous call (beyond 2,000 records).', required: false }), + }, + outputSchema: relatedRecordsOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const related = requireApiName({ value: p.related_list_api_name, name: 'related_list_api_name' }); + const page = optionalInt({ value: p.page, name: 'page', min: 1, max: 2000 }); + const perPage = optionalInt({ value: p.per_page, name: 'per_page', min: 1, max: 200 }) ?? 50; + const pageToken = p.page_token?.trim() || undefined; + validatePaging({ page, perPage, pageToken, tokenSupported: true }); + let fields = stringList(p.fields).map((f) => requireApiName({ value: f, name: 'fields' })); + if (fields.length > 50) throw new ZohoCrmError('Zoho returns at most 50 fields per call.'); + if (fields.length === 0) fields = await relatedModuleFields({ auth, module, related }); + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}/${id}/${encodeURIComponent(related)}`, + query: { + fields: fields.join(','), + per_page: String(perPage), + ...(page ? { page: String(page) } : {}), + ...(pageToken ? { page_token: pageToken } : {}), + }, + }); + const records = body?.data ?? []; + return { + module, + record_id: id, + related_list: related, + records, + count: records.length, + more_records: body?.info?.more_records === true, + next_page_token: body?.info?.next_page_token ?? null, + }; + }, +}); + +export const linkRelatedRecordAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_link_related_record', + classification: 'WRITE', + displayName: 'Link Related Record', + description: 'Associates a record with another record through a related list.', + audience: 'ai', + aiMetadata: { + description: + 'Associates an existing record with a Zoho CRM record through a related list, or updates the relation\'s own fields (e.g. add a Contact to a Campaign with a Member_Status, or a Product to a Deal). Supported relations include Campaigns with Leads/Contacts and Products with Leads/Accounts/Contacts/Deals/Price Books. Idempotent: linking the same pair again leaves one link.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + related_list_api_name: relatedList, + related_record_id: Property.ShortText({ displayName: 'Related Record ID', description: 'Id of the record to link.', required: true }), + relation_fields: Property.Json({ displayName: 'Relation Fields', description: 'Optional fields of the relation, e.g. {"Member_Status": "Invited"}.', required: false }), + }, + outputSchema: linkRelatedOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const related = requireApiName({ value: p.related_list_api_name, name: 'related_list_api_name' }); + const relatedId = requireId({ value: p.related_record_id, name: 'related_record_id' }); + const body = await zohoRequest({ + auth, + method: HttpMethod.PUT, + path: `/${encodeURIComponent(module)}/${id}/${encodeURIComponent(related)}`, + body: { data: [{ ...parseJsonObject({ value: p.relation_fields, name: 'relation_fields' }), id: relatedId }] }, + }); + const item = unwrapWriteItem({ body }); + return { + module, + record_id: id, + related_list: related, + related_record_id: relatedId, + status: item.status ?? null, + code: item.code ?? null, + message: item.message ?? null, + }; + }, +}); + +export const listAttachmentsAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_list_attachments', + classification: 'SEARCH', + displayName: 'List Attachments', + description: 'Lists the attachments of a record.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the attachments of one Zoho CRM record (file name, size, type, link URL, owner, attachment id), one page of up to 200 (page numbers reach the first 2,000). Use to find the attachment_id for Download Attachment; a "Link URL" attachment cannot be downloaded and carries its link_url instead. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + page: Property.Number({ displayName: 'Page', required: false }), + per_page: Property.Number({ displayName: 'Per Page', description: '1 to 200 (default 50).', required: false }), + }, + outputSchema: listAttachmentsOutputSchema, + async run({ auth, propsValue: p }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const page = optionalInt({ value: p.page, name: 'page', min: 1, max: 2000 }); + const perPage = optionalInt({ value: p.per_page, name: 'per_page', min: 1, max: 200 }) ?? 50; + validatePaging({ page, perPage, tokenSupported: false }); + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}/${id}/Attachments`, + query: { fields: ATTACHMENT_FIELDS, per_page: String(perPage), ...(page ? { page: String(page) } : {}) }, + }); + const attachments = (body?.data ?? []).map(flattenAttachment); + return { attachments, count: attachments.length, more_records: body?.info?.more_records === true }; + }, +}); + +export const uploadAttachmentAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_upload_attachment', + classification: 'WRITE', + displayName: 'Attach Link', + description: 'Attaches a URL to a record as a link attachment.', + audience: 'ai', + aiMetadata: { + description: + 'Adds a link attachment (a public http(s) URL plus an optional title) to one Zoho CRM record. Use to attach a document that lives online; Zoho stores the link, not a copy of the file. Not idempotent: each call adds another attachment.', + idempotent: false, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + attachment_url: Property.ShortText({ displayName: 'Attachment URL', description: 'Full URL, e.g. "https://example.com/proposal.pdf".', required: true }), + title: Property.ShortText({ displayName: 'Title', required: false }), + }, + outputSchema: childWriteOutputSchema, + async run({ auth, propsValue: p }) { + return uploadAttachment({ + auth, + module: requireApiName({ value: p.module_api_name, name: 'module_api_name' }), + recordId: requireId({ value: p.record_id, name: 'record_id' }), + attachmentUrl: p.attachment_url, + title: p.title, + }); + }, +}); + +export const downloadAttachmentAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_download_attachment', + classification: 'READ', + displayName: 'Download Attachment', + description: 'Downloads a file attachment of a record.', + audience: 'ai', + aiMetadata: { + description: + 'Downloads one file attachment of a Zoho CRM record by attachment id (from List Attachments) and returns it as a stored file. Link attachments cannot be downloaded. The file size limit of this Activepieces deployment applies. Read-only and idempotent.', + idempotent: true, + }, + props: { + module_api_name: moduleApiName, + record_id: recordId, + attachment_id: Property.ShortText({ displayName: 'Attachment ID', required: true }), + }, + outputSchema: downloadAttachmentOutputSchema, + async run({ auth, propsValue: p, files }) { + const module = requireApiName({ value: p.module_api_name, name: 'module_api_name' }); + const id = requireId({ value: p.record_id, name: 'record_id' }); + const attachmentId = requireId({ value: p.attachment_id, name: 'attachment_id' }); + const downloaded = await downloadToFile({ + auth, + url: zohoApiUrl({ auth, path: `/${encodeURIComponent(module)}/${id}/Attachments/${attachmentId}` }), + files, + fallbackName: `attachment-${attachmentId}`, + }); + return { + file: downloaded.file, + file_name: downloaded.fileName, + size: downloaded.size, + content_type: downloaded.contentType, + }; + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/ai/typed.ts b/packages/pieces/community/zoho-crm/src/lib/actions/ai/typed.ts new file mode 100644 index 000000000000..a0f8f668908c --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/ai/typed.ts @@ -0,0 +1,286 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../../auth'; +import { ZohoCrmError, optionalId, parseJsonObject, requireApiName, requireId } from '../../common/client'; +import { convertFieldValue } from '../../common/fields'; +import { convertLead, createRecord } from '../../common/records'; +import { aiWriteOutputSchema } from '../../output-schemas-ai'; +import { convertLeadOutputSchema } from '../../output-schemas'; + +const additionalFields = Property.Json({ + displayName: 'Additional Fields', + description: 'Optional JSON of other field API names, including custom fields, e.g. {"Custom_Field__c": "x"}. The named props above win over the same key here.', + required: false, +}); + +const ownerId = Property.ShortText({ + displayName: 'Owner User ID', + description: 'Optional user id (from List Users or Get Current User). Default: the connected user.', + required: false, +}); + +function compact(values: Record): Record { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function withCommon({ record, extra, owner }: { record: Record; extra: unknown; owner: unknown }): Record { + const id = optionalId({ value: owner, name: 'owner_id' }); + return { ...parseJsonObject({ value: extra, name: 'additional_fields' }), ...record, ...(id ? { Owner: { id } } : {}) }; +} + +function lookup({ value, name }: { value: unknown; name: string }): { id: string } | undefined { + const id = optionalId({ value, name }); + return id ? { id } : undefined; +} + +function dateOnly({ value, name }: { value: unknown; name: string }): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + return String(convertFieldValue({ field: { api_name: name, data_type: 'date' }, value })); +} + +function dateTime({ value, name }: { value: unknown; name: string }): string { + return String(convertFieldValue({ field: { api_name: name, data_type: 'datetime' }, value })); +} + +function num({ value, name }: { value: unknown; name: string }): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + return Number(convertFieldValue({ field: { api_name: name, data_type: 'double' }, value })); +} + +export const createLeadAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_lead', + classification: 'WRITE', + displayName: 'Create Lead', + description: 'Creates a lead.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one Zoho CRM lead with typed fields (Last Name required; company, email, phone, source, status) plus optional custom fields. Use for new prospects; use Upsert Record on Leads with Email to avoid duplicates, or Create Contact for an existing customer. Not idempotent: each call creates another lead.', + idempotent: false, + }, + props: { + last_name: Property.ShortText({ displayName: 'Last Name', required: true }), + first_name: Property.ShortText({ displayName: 'First Name', required: false }), + company: Property.ShortText({ displayName: 'Company', description: 'Required in many orgs\' layouts.', required: false }), + email: Property.ShortText({ displayName: 'Email', required: false }), + phone: Property.ShortText({ displayName: 'Phone', required: false }), + mobile: Property.ShortText({ displayName: 'Mobile', required: false }), + title: Property.ShortText({ displayName: 'Title', description: 'Job title (Designation).', required: false }), + website: Property.ShortText({ displayName: 'Website', required: false }), + lead_source: Property.ShortText({ displayName: 'Lead Source', description: 'A Lead_Source picklist value of your org, e.g. "Web Download".', required: false }), + lead_status: Property.ShortText({ displayName: 'Lead Status', description: 'A Lead_Status picklist value of your org, e.g. "Not Contacted".', required: false }), + description: Property.LongText({ displayName: 'Description', required: false }), + owner_id: ownerId, + additional_fields: additionalFields, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue: p }) { + const record = compact({ + Last_Name: p.last_name, + First_Name: p.first_name, + Company: p.company, + Email: p.email, + Phone: p.phone, + Mobile: p.mobile, + Designation: p.title, + Website: p.website, + Lead_Source: p.lead_source, + Lead_Status: p.lead_status, + Description: p.description, + }); + return createRecord({ auth, module: 'Leads', record: withCommon({ record, extra: p.additional_fields, owner: p.owner_id }) }); + }, +}); + +export const createContactAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_contact', + classification: 'WRITE', + displayName: 'Create Contact', + description: 'Creates a contact.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one Zoho CRM contact with typed fields (Last Name required; email, phone, account, mailing address) plus optional custom fields. Use for a person tied to a customer account; use Upsert Record on Contacts with Email to avoid duplicates. Not idempotent: each call creates another contact.', + idempotent: false, + }, + props: { + last_name: Property.ShortText({ displayName: 'Last Name', required: true }), + first_name: Property.ShortText({ displayName: 'First Name', required: false }), + email: Property.ShortText({ displayName: 'Email', required: false }), + phone: Property.ShortText({ displayName: 'Phone', required: false }), + mobile: Property.ShortText({ displayName: 'Mobile', required: false }), + title: Property.ShortText({ displayName: 'Title', required: false }), + account_id: Property.ShortText({ displayName: 'Account ID', description: 'Id of an existing Accounts record.', required: false }), + mailing_street: Property.ShortText({ displayName: 'Mailing Street', required: false }), + mailing_city: Property.ShortText({ displayName: 'Mailing City', required: false }), + mailing_state: Property.ShortText({ displayName: 'Mailing State', required: false }), + mailing_zip: Property.ShortText({ displayName: 'Mailing Zip', required: false }), + mailing_country: Property.ShortText({ displayName: 'Mailing Country', required: false }), + description: Property.LongText({ displayName: 'Description', required: false }), + owner_id: ownerId, + additional_fields: additionalFields, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue: p }) { + const record = compact({ + Last_Name: p.last_name, + First_Name: p.first_name, + Email: p.email, + Phone: p.phone, + Mobile: p.mobile, + Title: p.title, + Account_Name: lookup({ value: p.account_id, name: 'account_id' }), + Mailing_Street: p.mailing_street, + Mailing_City: p.mailing_city, + Mailing_State: p.mailing_state, + Mailing_Zip: p.mailing_zip, + Mailing_Country: p.mailing_country, + Description: p.description, + }); + return createRecord({ auth, module: 'Contacts', record: withCommon({ record, extra: p.additional_fields, owner: p.owner_id }) }); + }, +}); + +export const createDealAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_deal', + classification: 'WRITE', + displayName: 'Create Deal', + description: 'Creates a deal.', + audience: 'ai', + aiMetadata: { + description: + 'Creates one Zoho CRM deal (Deal Name and Stage required; amount, closing date, pipeline, account and contact optional). Stage and Pipeline values are org-specific, so read them from Get Module Fields on Deals first. Not idempotent: each call creates another deal.', + idempotent: false, + }, + props: { + deal_name: Property.ShortText({ displayName: 'Deal Name', required: true }), + stage: Property.ShortText({ displayName: 'Stage', description: 'A Stage picklist value of your org, e.g. "Qualification".', required: true }), + closing_date: Property.ShortText({ displayName: 'Closing Date', description: 'yyyy-MM-dd, e.g. "2026-12-31". Required in many layouts.', required: false }), + amount: Property.Number({ displayName: 'Amount', required: false }), + pipeline: Property.ShortText({ displayName: 'Pipeline', description: 'Pipeline name when the org uses pipelines, e.g. "Standard (Standard)".', required: false }), + account_id: Property.ShortText({ displayName: 'Account ID', required: false }), + contact_id: Property.ShortText({ displayName: 'Contact ID', required: false }), + description: Property.LongText({ displayName: 'Description', required: false }), + owner_id: ownerId, + additional_fields: additionalFields, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue: p }) { + const record = compact({ + Deal_Name: p.deal_name, + Stage: p.stage, + Closing_Date: dateOnly({ value: p.closing_date, name: 'closing_date' }), + Amount: num({ value: p.amount, name: 'amount' }), + Pipeline: p.pipeline, + Account_Name: lookup({ value: p.account_id, name: 'account_id' }), + Contact_Name: lookup({ value: p.contact_id, name: 'contact_id' }), + Description: p.description, + }); + return createRecord({ auth, module: 'Deals', record: withCommon({ record, extra: p.additional_fields, owner: p.owner_id }) }); + }, +}); + +export const createEventAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_create_event', + classification: 'WRITE', + displayName: 'Create Event', + description: 'Creates a calendar event (meeting).', + audience: 'ai', + aiMetadata: { + description: + 'Creates one Zoho CRM event (meeting) with a title, start and end date-time, optional venue and an optional link to a record (What_Id plus its module, e.g. a Deal or Account). Use to log or schedule a meeting against a CRM record. Not idempotent: each call creates another event.', + idempotent: false, + }, + props: { + event_title: Property.ShortText({ displayName: 'Title', required: true }), + start_datetime: Property.ShortText({ displayName: 'Start', description: 'ISO 8601 with offset, e.g. "2026-10-01T09:00:00+02:00".', required: true }), + end_datetime: Property.ShortText({ displayName: 'End', description: 'ISO 8601 with offset, after Start.', required: true }), + all_day: Property.Checkbox({ displayName: 'All Day', required: false, defaultValue: false }), + venue: Property.ShortText({ displayName: 'Venue', required: false }), + description: Property.LongText({ displayName: 'Description', required: false }), + related_module: Property.ShortText({ displayName: 'Related Module', description: 'Module API name of the record to link (What_Id), e.g. "Deals" or "Accounts". Give with related_record_id.', required: false }), + related_record_id: Property.ShortText({ displayName: 'Related Record ID', required: false }), + owner_id: ownerId, + additional_fields: additionalFields, + }, + outputSchema: aiWriteOutputSchema, + async run({ auth, propsValue: p }) { + const start = dateTime({ value: p.start_datetime, name: 'start_datetime' }); + const end = dateTime({ value: p.end_datetime, name: 'end_datetime' }); + if (Date.parse(end) < Date.parse(start)) { + throw new ZohoCrmError('end_datetime must not be before start_datetime.'); + } + const relatedId = optionalId({ value: p.related_record_id, name: 'related_record_id' }); + if ((relatedId === undefined) !== (!p.related_module)) { + throw new ZohoCrmError('Give related_module and related_record_id together.'); + } + const record = compact({ + Event_Title: p.event_title, + Start_DateTime: start, + End_DateTime: end, + All_day: p.all_day === true ? true : undefined, + Venue: p.venue, + Description: p.description, + What_Id: relatedId ? { id: relatedId } : undefined, + $se_module: relatedId && p.related_module ? requireApiName({ value: p.related_module, name: 'related_module' }) : undefined, + }); + return createRecord({ auth, module: 'Events', record: withCommon({ record, extra: p.additional_fields, owner: p.owner_id }) }); + }, +}); + +export const convertLeadAtomic = createAction({ + auth: zohoCrmAuth, + name: 'zoho_crm_convert_lead', + classification: 'WRITE', + displayName: 'Convert Lead', + description: 'Converts a lead to a contact, account and optional deal.', + audience: 'ai', + aiMetadata: { + description: + 'Converts one Zoho CRM lead into a contact and account (new, or existing ones by id) and optionally creates a deal when deal_name is given (deal_stage and deal_closing_date then required). Use once a lead is qualified. Not idempotent: a converted lead cannot be converted again (ID_ALREADY_CONVERTED).', + idempotent: false, + }, + props: { + lead_id: Property.ShortText({ displayName: 'Lead ID', required: true }), + account_id: Property.ShortText({ displayName: 'Existing Account ID', required: false }), + contact_id: Property.ShortText({ displayName: 'Existing Contact ID', required: false }), + assign_to: Property.ShortText({ displayName: 'Owner User ID', required: false }), + overwrite: Property.Checkbox({ displayName: 'Overwrite Contact Account', required: false, defaultValue: false }), + notify_lead_owner: Property.Checkbox({ displayName: 'Notify Lead Owner', required: false, defaultValue: false }), + notify_new_entity_owner: Property.Checkbox({ displayName: 'Notify New Owner', required: false, defaultValue: false }), + deal_name: Property.ShortText({ displayName: 'Deal Name', description: 'Set to also create a deal.', required: false }), + deal_stage: Property.ShortText({ displayName: 'Deal Stage', required: false }), + deal_closing_date: Property.ShortText({ displayName: 'Deal Closing Date', description: 'yyyy-MM-dd.', required: false }), + deal_amount: Property.Number({ displayName: 'Deal Amount', required: false }), + deal_pipeline: Property.ShortText({ displayName: 'Deal Pipeline', required: false }), + }, + outputSchema: convertLeadOutputSchema, + async run({ auth, propsValue: p }) { + if (p.deal_name && (!p.deal_stage || !p.deal_closing_date)) { + throw new ZohoCrmError('deal_stage and deal_closing_date are required when deal_name is set.'); + } + const deal = p.deal_name + ? compact({ + Deal_Name: p.deal_name, + Stage: p.deal_stage, + Closing_Date: dateOnly({ value: p.deal_closing_date, name: 'deal_closing_date' }), + Amount: num({ value: p.deal_amount, name: 'deal_amount' }), + Pipeline: p.deal_pipeline, + }) + : {}; + return convertLead({ + auth, + leadId: requireId({ value: p.lead_id, name: 'lead_id' }), + accountId: optionalId({ value: p.account_id, name: 'account_id' }), + contactId: optionalId({ value: p.contact_id, name: 'contact_id' }), + assignTo: optionalId({ value: p.assign_to, name: 'assign_to' }), + overwrite: p.overwrite === true ? true : undefined, + notifyLeadOwner: p.notify_lead_owner === true ? true : undefined, + notifyNewEntityOwner: p.notify_new_entity_owner === true ? true : undefined, + deal, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/convert-lead.ts b/packages/pieces/community/zoho-crm/src/lib/actions/convert-lead.ts new file mode 100644 index 000000000000..3d56c38f46be --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/convert-lead.ts @@ -0,0 +1,76 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { ZohoCrmError, optionalId, parseJsonObject, requireId } from '../common/client'; +import { convertFieldValue } from '../common/fields'; +import { recordDropdown, userDropdown } from '../common/props'; +import { convertLead } from '../common/records'; +import { convertLeadOutputSchema } from '../output-schemas'; + +export const convertLeadAction = createAction({ + auth: zohoCrmAuth, + name: 'convert_lead', + classification: 'WRITE', + displayName: 'Convert Lead', + description: 'Converts a lead into a contact and account, and optionally a deal.', + audience: 'human', + aiMetadata: { + description: + 'Converts one Zoho CRM lead into a contact plus an account (or links it to existing ones) and can create a deal in the same call. Use when a qualified lead should become a customer record. Not idempotent: a lead can be converted only once and a second call fails with ID_ALREADY_CONVERTED.', + idempotent: false, + }, + props: { + lead_id: recordDropdown({ displayName: 'Lead', fixedModule: 'Leads' }), + account_id: recordDropdown({ + displayName: 'Existing Account', + description: 'Link to this account; the lead\'s Company must match its name.', + fixedModule: 'Accounts', + required: false, + }), + contact_id: recordDropdown({ + displayName: 'Existing Contact', + description: 'Merge into this contact; the lead\'s name and email must match.', + fixedModule: 'Contacts', + required: false, + }), + overwrite: Property.Checkbox({ displayName: 'Overwrite Contact\'s Account', description: 'Move the existing contact to the given account.', required: false, defaultValue: false }), + assign_to: userDropdown({ + displayName: 'Owner', + description: 'Active user who owns the new records; empty keeps the lead owner.', + }), + notify_lead_owner: Property.Checkbox({ displayName: 'Notify Lead Owner', required: false, defaultValue: false }), + notify_new_entity_owner: Property.Checkbox({ displayName: 'Notify New Owner', required: false, defaultValue: false }), + create_deal: Property.Checkbox({ displayName: 'Create a Deal', required: false, defaultValue: false }), + deal_name: Property.ShortText({ displayName: 'Deal Name', required: false }), + deal_stage: Property.ShortText({ displayName: 'Deal Stage', description: 'A Stage picklist value of your org, e.g. "Qualification".', required: false }), + deal_closing_date: Property.ShortText({ displayName: 'Deal Closing Date', description: 'Date as yyyy-MM-dd, e.g. 2026-12-31.', required: false }), + deal_amount: Property.Number({ displayName: 'Deal Amount', required: false }), + deal_pipeline: Property.ShortText({ displayName: 'Deal Pipeline', description: 'Required when your org uses pipelines, e.g. "Standard (Standard)".', required: false }), + deal_extra: Property.Json({ displayName: 'Other Deal Fields (JSON)', required: false }), + }, + outputSchema: convertLeadOutputSchema, + async run({ auth, propsValue }) { + const deal: Record = {}; + if (propsValue.create_deal === true) { + Object.assign(deal, parseJsonObject({ value: propsValue.deal_extra, name: 'Other Deal Fields' })); + if (!propsValue.deal_name || !propsValue.deal_stage || !propsValue.deal_closing_date) { + throw new ZohoCrmError('To create a deal, Deal Name, Deal Stage and Deal Closing Date are required.'); + } + deal['Deal_Name'] = propsValue.deal_name; + deal['Stage'] = propsValue.deal_stage; + deal['Closing_Date'] = convertFieldValue({ field: { api_name: 'Deal Closing Date', data_type: 'date' }, value: propsValue.deal_closing_date }); + if (propsValue.deal_amount !== undefined && propsValue.deal_amount !== null) deal['Amount'] = Number(propsValue.deal_amount); + if (propsValue.deal_pipeline) deal['Pipeline'] = propsValue.deal_pipeline; + } + return convertLead({ + auth, + leadId: requireId({ value: propsValue.lead_id, name: 'Lead' }), + accountId: optionalId({ value: propsValue.account_id, name: 'Existing Account' }), + contactId: optionalId({ value: propsValue.contact_id, name: 'Existing Contact' }), + assignTo: optionalId({ value: propsValue.assign_to, name: 'Owner' }), + overwrite: propsValue.overwrite === true ? true : undefined, + notifyLeadOwner: propsValue.notify_lead_owner === true ? true : undefined, + notifyNewEntityOwner: propsValue.notify_new_entity_owner === true ? true : undefined, + deal, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/create-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/create-record.ts new file mode 100644 index 000000000000..8646f2af8636 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/create-record.ts @@ -0,0 +1,54 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { WORKFLOW_TRIGGERS, parseTriggers, requireApiName } from '../common/client'; +import { buildRecordPayload } from '../common/fields'; +import { additionalFieldsProp, moduleDropdown, recordFieldsProp } from '../common/props'; +import { createRecord, tryListFields } from '../common/records'; +import { recordWriteOutputSchema } from '../output-schemas'; + +export const createRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'create_record', + classification: 'WRITE', + displayName: 'Create Record', + description: 'Creates a record in any module, including custom fields.', + audience: 'human', + aiMetadata: { + description: + 'Creates one record in a chosen Zoho CRM module (standard or custom) from a form of that module\'s fields, custom fields included. Use to add a lead, contact, deal or custom-module record; use Create or Update Record instead when the record may already exist. Not idempotent: each call creates a new record.', + idempotent: false, + }, + props: { + module: moduleDropdown({ filter: 'creatable' }), + fields: recordFieldsProp({ mode: 'create' }), + additional_fields: additionalFieldsProp(), + triggers: Property.StaticMultiSelectDropdown({ + displayName: 'Automation to Run', + description: 'Empty runs Zoho\'s default workflows; pick some to run only those.', + required: false, + options: { options: WORKFLOW_TRIGGERS.map((t) => ({ label: t, value: t })) }, + }), + skip_automation: Property.Checkbox({ + displayName: 'Skip All Automation', + description: 'Run no workflow, approval or blueprint for this record.', + required: false, + defaultValue: false, + }), + }, + outputSchema: recordWriteOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module, name: 'Module' }); + const fields = await tryListFields({ auth, module }); + const record = buildRecordPayload({ + dynamicValues: propsValue.fields, + fields, + extraFields: propsValue.additional_fields, + }); + return createRecord({ + auth, + module, + record, + trigger: parseTriggers({ value: propsValue.triggers, skipAll: propsValue.skip_automation }), + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/delete-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/delete-record.ts new file mode 100644 index 000000000000..20e1299f3771 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/delete-record.ts @@ -0,0 +1,39 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName, requireId } from '../common/client'; +import { moduleDropdown, recordDropdown } from '../common/props'; +import { deleteRecord } from '../common/records'; +import { deleteOutputSchema } from '../output-schemas'; + +export const deleteRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'delete_record', + classification: 'DESTRUCTIVE', + displayName: 'Delete Record', + description: 'Deletes a record (it moves to the Zoho CRM Recycle Bin).', + audience: 'human', + aiMetadata: { + description: + 'Deletes one Zoho CRM record by module and id; it moves to the Recycle Bin and can be restored from the Zoho UI. Use only when the record must go; to change it use Update Record. Not idempotent: deleting the same id again returns an error.', + idempotent: false, + }, + props: { + module: moduleDropdown({ filter: 'deletable' }), + record_id: recordDropdown(), + run_workflows: Property.Checkbox({ + displayName: 'Run Workflows', + description: 'Run delete workflows configured in Zoho (Zoho default: on).', + required: false, + defaultValue: true, + }), + }, + outputSchema: deleteOutputSchema, + async run({ auth, propsValue }) { + return deleteRecord({ + auth, + module: requireApiName({ value: propsValue.module, name: 'Module' }), + id: requireId({ value: propsValue.record_id, name: 'Record' }), + runWorkflows: propsValue.run_workflows !== false, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/get-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/get-record.ts new file mode 100644 index 000000000000..cc880607f49d --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/get-record.ts @@ -0,0 +1,32 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName, requireId } from '../common/client'; +import { moduleDropdown, recordDropdown } from '../common/props'; +import { getRecord } from '../common/records'; +import { recordOutputSchema } from '../output-schemas'; + +export const getRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'get_record', + classification: 'READ', + displayName: 'Get Record', + description: 'Gets a record by id from any module, with all its fields.', + audience: 'human', + aiMetadata: { + description: + 'Fetches one Zoho CRM record by module and id with all its fields (keys are field API names, custom fields included). Use when you know the record id. Read-only and idempotent.', + idempotent: true, + }, + props: { + module: moduleDropdown(), + record_id: recordDropdown(), + }, + outputSchema: recordOutputSchema, + async run({ auth, propsValue }) { + return getRecord({ + auth, + module: requireApiName({ value: propsValue.module, name: 'Module' }), + id: requireId({ value: propsValue.record_id, name: 'Record' }), + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/read-file.ts b/packages/pieces/community/zoho-crm/src/lib/actions/read-file.ts index e61509b40fae..93afcc00a455 100644 --- a/packages/pieces/community/zoho-crm/src/lib/actions/read-file.ts +++ b/packages/pieces/community/zoho-crm/src/lib/actions/read-file.ts @@ -1,48 +1,39 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; import { zohoCrmAuth } from '../auth'; -import { Property, createAction } from "@activepieces/pieces-framework"; - +import { downloadToFile, lastPathSegment } from '../common/download'; export const readFile = createAction({ - auth: zohoCrmAuth, - name: 'read-file', - classification: 'READ', - displayName: 'Read file', - description: 'Download a file content from Zoho CRM. e.g.: a Backup File', - audience: 'both', - aiMetadata: { description: 'Downloads the binary content of a file from Zoho CRM (e.g. a backup or attachment) given its full URL, authenticating with the connected account, and returns it as a stored file. Use when you already hold a Zoho CRM file URL and need its bytes for a later step. Requires the complete URL including the base host; read-only, so repeating the call is safe.', idempotent: true }, - props: { - url: Property.ShortText({ - displayName: 'URL', - description: 'The full URL to use, including the base URL', - required: true, - defaultValue: '', - }) - }, - run: async ({ auth, propsValue, files }) => { - const url = propsValue['url']; - - const download = await fetch(url, { - headers: { - Authorization: `Bearer ${auth.access_token}`, - }, - }) - .then((response) => - response.ok ? response.blob() : Promise.reject(response) - ) - .catch((error) => - Promise.reject( - new Error( - `Error when download file:\n\tDownload file response: ${(error as Error).message ?? error - }` - ) - ) - ); - - const fileName = url.split('/').pop() ?? url; - - return files.write({ - fileName: fileName, - data: Buffer.from(await download.arrayBuffer()), - }); - }, + auth: zohoCrmAuth, + name: 'read-file', + classification: 'READ', + displayName: 'Read file', + description: 'Download a file content from Zoho CRM. e.g.: a Backup File', + audience: 'both', + aiMetadata: { + description: + 'Downloads the binary content of a Zoho CRM file (e.g. a data backup or bulk-read result) from its full https URL and returns it as a stored file. Only URLs on the connection\'s own Zoho API host under /crm/ or its data centre\'s download host are accepted, because the token is sent with the request. The file size limit of this Activepieces deployment applies. Read-only and idempotent.', + idempotent: true, + }, + props: { + url: Property.ShortText({ + displayName: 'URL', + description: 'Full https URL on your Zoho API host (/crm/...) or download host.', + required: true, + defaultValue: '', + }), + }, + run: async ({ auth, propsValue, files }) => { + const url = propsValue['url'].trim(); + const fileName = lastPathSegmentOf(url); + const downloaded = await downloadToFile({ auth, url, files, fileName }); + return downloaded.file; + }, }); + +function lastPathSegmentOf(url: string): string | undefined { + try { + return lastPathSegment(new URL(url)); + } catch { + return undefined; + } +} diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/update-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/update-record.ts new file mode 100644 index 000000000000..495fd4b91275 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/update-record.ts @@ -0,0 +1,52 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { parseTriggers, requireApiName, requireId, stringList } from '../common/client'; +import { buildRecordPayload } from '../common/fields'; +import { additionalFieldsProp, moduleDropdown, recordDropdown, recordFieldsProp } from '../common/props'; +import { tryListFields, updateRecord } from '../common/records'; +import { recordWriteOutputSchema } from '../output-schemas'; + +export const updateRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'update_record', + classification: 'WRITE', + displayName: 'Update Record', + description: 'Updates fields of a record in any module. Fields left empty are not changed.', + audience: 'human', + aiMetadata: { + description: + 'Updates selected fields of one existing Zoho CRM record by module and id; fields left empty keep their current value, and "Clear Fields" blanks named fields. Multi-select picklists are replaced, not merged. Not idempotent: every call counts as an edit and runs the module\'s on-edit workflows unless automation is skipped.', + idempotent: false, + }, + props: { + module: moduleDropdown({ filter: 'editable' }), + record_id: recordDropdown(), + fields: recordFieldsProp({ mode: 'update' }), + clear_fields: Property.Array({ + displayName: 'Clear Fields', + description: 'Field API names to set to empty (e.g. "Phone", "Custom_Field__c").', + required: false, + }), + additional_fields: additionalFieldsProp(), + skip_automation: Property.Checkbox({ + displayName: 'Skip All Automation', + description: 'Run no workflow, approval or blueprint for this update.', + required: false, + defaultValue: false, + }), + }, + outputSchema: recordWriteOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module, name: 'Module' }); + const id = requireId({ value: propsValue.record_id, name: 'Record' }); + const clearFields = stringList(propsValue.clear_fields).map((f) => requireApiName({ value: f, name: 'Clear Fields' })); + const fields = await tryListFields({ auth, module }); + const record = buildRecordPayload({ + dynamicValues: propsValue.fields, + fields, + extraFields: propsValue.additional_fields, + clearFields, + }); + return updateRecord({ auth, module, id, record, trigger: parseTriggers({ value: undefined, skipAll: propsValue.skip_automation }) }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/upload-attachment.ts b/packages/pieces/community/zoho-crm/src/lib/actions/upload-attachment.ts new file mode 100644 index 000000000000..92784b96b084 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/upload-attachment.ts @@ -0,0 +1,36 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName, requireId } from '../common/client'; +import { moduleDropdown, recordDropdown } from '../common/props'; +import { readApFile, uploadAttachment } from '../common/records'; +import { childWriteOutputSchema } from '../output-schemas'; + +export const uploadAttachmentAction = createAction({ + auth: zohoCrmAuth, + name: 'upload_attachment', + classification: 'WRITE', + displayName: 'Upload Attachment', + description: 'Attaches a file to a record.', + audience: 'human', + aiMetadata: { + description: + 'Uploads a file from the flow as an attachment on one Zoho CRM record in any module. Use when you hold file bytes from an earlier step. Not idempotent: each call adds another attachment.', + idempotent: false, + }, + props: { + module: moduleDropdown(), + record_id: recordDropdown(), + file: Property.File({ displayName: 'File', required: true }), + file_name: Property.ShortText({ displayName: 'File Name', description: 'Optional. Overrides the file name.', required: false }), + }, + outputSchema: childWriteOutputSchema, + async run({ auth, propsValue }) { + const file = readApFile(propsValue.file); + return uploadAttachment({ + auth, + module: requireApiName({ value: propsValue.module, name: 'Module' }), + recordId: requireId({ value: propsValue.record_id, name: 'Record' }), + file: { filename: propsValue.file_name || file.filename, data: file.data }, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/actions/upsert-record.ts b/packages/pieces/community/zoho-crm/src/lib/actions/upsert-record.ts new file mode 100644 index 000000000000..ddf2b43b3a4a --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/actions/upsert-record.ts @@ -0,0 +1,45 @@ +import { Property, createAction } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { parseTriggers, requireApiName, stringList } from '../common/client'; +import { buildRecordPayload } from '../common/fields'; +import { additionalFieldsProp, moduleDropdown, recordFieldsProp, uniqueFieldsDropdown } from '../common/props'; +import { tryListFields, upsertRecord } from '../common/records'; +import { upsertOutputSchema } from '../output-schemas'; + +export const upsertRecordAction = createAction({ + auth: zohoCrmAuth, + name: 'upsert_record', + classification: 'WRITE', + displayName: 'Create or Update Record', + description: 'Updates the record that matches on the duplicate-check fields, or creates it if none matches.', + audience: 'human', + aiMetadata: { + description: + 'Inserts or updates one Zoho CRM record, matching an existing record on duplicate-check fields (Zoho default: Email for Leads/Contacts, Account_Name for Accounts, Deal_Name for Deals). Use for find-or-create without search. Not idempotent: when the data has no value for a duplicate-check field, every call inserts a new record.', + idempotent: false, + }, + props: { + module: moduleDropdown({ filter: 'creatable' }), + duplicate_check_fields: uniqueFieldsDropdown(), + fields: recordFieldsProp({ mode: 'upsert' }), + additional_fields: additionalFieldsProp(), + skip_automation: Property.Checkbox({ + displayName: 'Skip All Automation', + required: false, + defaultValue: false, + }), + }, + outputSchema: upsertOutputSchema, + async run({ auth, propsValue }) { + const module = requireApiName({ value: propsValue.module, name: 'Module' }); + const fields = await tryListFields({ auth, module }); + const record = buildRecordPayload({ dynamicValues: propsValue.fields, fields, extraFields: propsValue.additional_fields }); + return upsertRecord({ + auth, + module, + record, + duplicateCheckFields: stringList(propsValue.duplicate_check_fields).map((f) => requireApiName({ value: f, name: 'Duplicate Check Fields' })), + trigger: parseTriggers({ value: undefined, skipAll: propsValue.skip_automation }), + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/common/client.ts b/packages/pieces/community/zoho-crm/src/lib/common/client.ts new file mode 100644 index 000000000000..a7e06e4da398 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/client.ts @@ -0,0 +1,456 @@ +import { + HttpMethod, + HttpRequest, + QueryParams, + httpClient, +} from '@activepieces/pieces-common'; +import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; + +export type ZohoAuth = AppConnectionValueForAuthProperty; + +const ZOHO_API_VERSION = 'v8'; + +export class ZohoCrmError extends Error { + constructor( + message: string, + readonly status?: number, + readonly code?: string, + ) { + super(message); + this.name = 'ZohoCrmError'; + } +} + +const FALLBACK_API_DOMAIN_BY_LOCATION: Record = { + 'zoho.com': 'https://www.zohoapis.com', + 'zoho.eu': 'https://www.zohoapis.eu', + 'zoho.in': 'https://www.zohoapis.in', + 'zoho.com.au': 'https://www.zohoapis.com.au', + 'zoho.jp': 'https://www.zohoapis.jp', + 'zohocloud.ca': 'https://www.zohoapis.ca', +}; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function readField({ value, key }: { value: unknown; key: string }): unknown { + return isRecord(value) ? value[key] : undefined; +} + +export function errorText(error: unknown): string { + return error instanceof Error ? error.message.slice(0, 200) : 'unknown error'; +} + +export function getApiDomain(auth: { data?: Record; props?: Record }): string { + const domain = auth.data?.['api_domain']; + if (typeof domain === 'string' && domain.length > 0) { + return domain.replace(/\/+$/, ''); + } + const location = auth.props?.['location']; + const fallback = typeof location === 'string' ? FALLBACK_API_DOMAIN_BY_LOCATION[location] : undefined; + if (fallback) { + return fallback; + } + throw new ZohoCrmError( + 'This Zoho CRM connection has no api_domain (it is set by Zoho when you connect). Reconnect the Zoho CRM connection and try again.', + ); +} + +export function customApiAuthHeaders({ + auth, + url, +}: { + auth: { access_token: string; data?: Record; props?: Record }; + url: unknown; +}): Record { + const apiHost = new URL(getApiDomain(auth)).host.toLowerCase(); + if (typeof url === 'string' && /^https?:\/\//i.test(url.trim())) { + if (!isApiHostUrl({ raw: url.trim(), apiHost })) { + throw new ZohoCrmError( + `Custom API Call sends the Zoho CRM token only to https://${apiHost}. Use a path such as /Leads, or a full https://${apiHost}/crm/... URL.`, + ); + } + } + return authHeaders(auth); +} + +function isApiHostUrl({ raw, apiHost }: { raw: string; apiHost: string }): boolean { + try { + const parsed = new URL(raw); + return parsed.protocol === 'https:' && parsed.username === '' && parsed.password === '' && parsed.host.toLowerCase() === apiHost; + } catch { + return false; + } +} + +export function authHeaders(auth: { access_token: string }): Record { + return { Authorization: `Zoho-oauthtoken ${auth.access_token}` }; +} + +function readResponse(error: unknown): { status?: number; body?: unknown } | undefined { + const response = readField({ value: error, key: 'response' }); + if (!isRecord(response)) { + return undefined; + } + const status = response['status']; + return { + status: typeof status === 'number' ? status : undefined, + body: response['body'], + }; +} + +function toErrorBody(record: Record): ZohoErrorBody { + const details = record['details']; + return { + code: readString(record['code']), + message: readString(record['message']), + status: readString(record['status']), + details: isRecord(details) ? details : undefined, + }; +} + +function firstErrorBody(body: unknown): ZohoErrorBody | undefined { + if (typeof body === 'string') { + try { + return firstErrorBody(JSON.parse(body)); + } catch { + return { message: body }; + } + } + if (!isRecord(body)) { + return undefined; + } + for (const key of ['data', 'tags', '__email_drafts']) { + const list = body[key]; + if (Array.isArray(list) && list.length > 0 && isRecord(list[0])) { + return toErrorBody(list[0]); + } + } + return toErrorBody(body); +} + +function describeZohoError({ error, status }: { error: ZohoErrorBody; status?: number }): string { + const code = error.code ?? 'ERROR'; + const field = error.details?.['api_name']; + const param = error.details?.['param_name']; + const parts = [`Zoho CRM ${code}${status ? ` (HTTP ${status})` : ''}: ${error.message ?? 'request failed'}`]; + if (typeof field === 'string') { + parts.push(`field: ${field}`); + } + if (typeof param === 'string') { + parts.push(`parameter: ${param}`); + } + const hint = HINTS[code]; + if (hint) { + parts.push(hint); + } + return parts.join(' — '); +} + +const HINTS: Record = { + OAUTH_SCOPE_MISMATCH: + 'the connection was not granted the OAuth scope this endpoint needs; reconnect it (or this action needs a scope the piece does not request yet)', + INVALID_TOKEN: 'the access token is invalid or expired; reconnect the Zoho CRM connection', + AUTHENTICATION_FAILURE: 'reconnect the Zoho CRM connection', + NO_PERMISSION: 'the connected Zoho user lacks permission for this module or record', + INVALID_MODULE: 'use a module API name such as "Leads", "Deals" or "CustomModule1"', + INVALID_URL_PATTERN: 'check the module API name and record id', + INVALID_DATA: 'check the field API names and value formats', + MANDATORY_NOT_FOUND: 'a required field is missing', + DUPLICATE_DATA: 'a record with the same unique value already exists (update it, or create-or-update on that field instead)', + ID_ALREADY_CONVERTED: 'the lead has already been converted', + TOO_MANY_REQUESTS: 'rate limit reached; retry later', + INTEGRATION_NOT_ENABLED: + 'this org stores file attachments in Zoho WorkDrive and the integration is off; an admin must enable it (Setup, Marketplace, Zoho, WorkDrive), or attach a link instead', + DOWNLOAD_NOT_ALLOWED: 'link attachments have no file to download; open their link_url instead', +}; + +export function toZohoError(error: unknown): Error { + const response = readResponse(error); + if (!response) { + return error instanceof Error ? error : new Error(String(error)); + } + const parsed = firstErrorBody(response.body); + if (response.status === 429) { + return new ZohoCrmError(describeZohoError({ error: { ...parsed, code: parsed?.code ?? 'TOO_MANY_REQUESTS' }, status: 429 }), 429, 'TOO_MANY_REQUESTS'); + } + return new ZohoCrmError(describeZohoError({ error: parsed ?? {}, status: response.status }), response.status, parsed?.code); +} + +export function zohoApiUrl({ auth, path, version = ZOHO_API_VERSION }: { auth: ZohoAuth; path: string; version?: string }): string { + return `${getApiDomain(auth)}/crm/${version}${path}`; +} + +export async function zohoRequestRaw(req: ZohoRequest): Promise<{ status: number; body: T | undefined; headers: Record }> { + const url = zohoApiUrl({ auth: req.auth, path: req.path, version: req.version }); + try { + const response = await httpClient.sendRequest({ + method: req.method, + url, + headers: { ...authHeaders(req.auth), ...(req.headers ?? {}) }, + queryParams: req.query, + body: req.body, + responseType: req.responseType, + }); + return { status: response.status, body: response.body, headers: response.headers ?? {} }; + } catch (error) { + throw toZohoError(error); + } +} + +export async function zohoRequest(req: ZohoRequest): Promise { + const { status, body } = await zohoRequestRaw(req); + if (status === 204) { + return undefined; + } + return body; +} + +function toWriteItem(value: unknown): ZohoWriteItem { + if (!isRecord(value)) { + return {}; + } + const details = value['details']; + const duplicateField = value['duplicate_field']; + return { + code: readString(value['code']), + status: readString(value['status']), + message: readString(value['message']), + action: readString(value['action']), + duplicate_field: typeof duplicateField === 'string' ? duplicateField : null, + details: isRecord(details) ? details : undefined, + }; +} + +function unwrapWriteItems({ body, key = 'data' }: { body: unknown; key?: string }): ZohoWriteItem[] { + const list = readField({ value: body, key }); + if (!Array.isArray(list) || list.length === 0) { + throw new ZohoCrmError(`Zoho CRM returned an unexpected response (no "${key}" array).`); + } + const items = list.map(toWriteItem); + const failed = items.find((item) => item.status === 'error'); + if (failed) { + throw new ZohoCrmError(describeZohoError({ error: failed }), undefined, failed.code); + } + return items; +} + +export function unwrapWriteItem({ body, key = 'data' }: { body: unknown; key?: string }): ZohoWriteItem { + return unwrapWriteItems({ body, key })[0]; +} + +function userRef(value: unknown): { id: string | null; name: string | null } { + return { + id: readString(readField({ value, key: 'id' })) ?? null, + name: readString(readField({ value, key: 'name' })) ?? null, + }; +} + +export function flattenWriteResult(item: ZohoWriteItem): Record { + const d = item.details ?? {}; + const createdBy = userRef(d['Created_By']); + const modifiedBy = userRef(d['Modified_By']); + return { + id: readString(d['id']) ?? null, + status: item.status ?? null, + code: item.code ?? null, + message: item.message ?? null, + action: item.action ?? null, + duplicate_field: item.duplicate_field ?? null, + created_time: readString(d['Created_Time']) ?? null, + modified_time: readString(d['Modified_Time']) ?? null, + created_by_id: createdBy.id, + created_by_name: createdBy.name, + modified_by_id: modifiedBy.id, + modified_by_name: modifiedBy.name, + }; +} + +const ID_RE = /^[0-9]{1,25}$/; +const API_NAME_RE = /^[A-Za-z0-9_$]{1,100}$/; +const KEY_RE = /^[A-Za-z0-9]{1,200}$/; + +export function requireId({ value, name }: { value: unknown; name: string }): string { + const s = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''; + if (!ID_RE.test(s)) { + throw new ZohoCrmError(`${name} must be a numeric Zoho id such as "5725767000000524157" (got "${String(value ?? '')}").`); + } + return s; +} + +export function requireKey({ value, name }: { value: unknown; name: string }): string { + const s = typeof value === 'string' ? value.trim() : ''; + if (!KEY_RE.test(s)) { + throw new ZohoCrmError(`${name} must be the id string returned by Zoho (letters and digits only).`); + } + return s; +} + +export function optionalId({ value, name }: { value: unknown; name: string }): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + return requireId({ value, name }); +} + +export function requireApiName({ value, name }: { value: unknown; name: string }): string { + const s = typeof value === 'string' ? value.trim() : ''; + if (!API_NAME_RE.test(s)) { + throw new ZohoCrmError(`${name} must be a Zoho API name such as "Leads" or "Custom_Field__c" (got "${String(value ?? '')}").`); + } + return s; +} + +export function optionalInt({ value, name, min, max }: { value: unknown; name: string; min: number; max: number }): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(n) || n < min || n > max) { + throw new ZohoCrmError(`${name} must be a whole number between ${min} and ${max}.`); + } + return n; +} + +export function validatePaging({ + page, + perPage, + pageToken, + tokenSupported, +}: { + page?: number; + perPage: number; + pageToken?: string; + tokenSupported: boolean; +}): void { + if (page !== undefined && pageToken) { + throw new ZohoCrmError('Use either page or page_token, not both.'); + } + if (page !== undefined && page * perPage > MAX_PAGED_ITEMS) { + throw new ZohoCrmError( + `Zoho only serves the first ${MAX_PAGED_ITEMS.toLocaleString('en-US')} items by page number (page × per_page).${ + tokenSupported ? ' Pass next_page_token from the previous result instead.' : '' + }`, + ); + } +} + +const MAX_PAGED_ITEMS = 2000; + +export function parseJsonObject({ value, name }: { value: unknown; name: string }): Record { + if (value === undefined || value === null || value === '') { + return {}; + } + const parsed = typeof value === 'string' ? parseJsonText({ text: value, name }) : value; + if (!isRecord(parsed)) { + throw new ZohoCrmError(`${name} must be a JSON object, e.g. {"Lead_Source": "Web"}.`); + } + return parsed; +} + +function parseJsonText({ text, name }: { text: string; name: string }): unknown { + try { + return JSON.parse(text); + } catch { + throw new ZohoCrmError(`${name} must be a JSON object, e.g. {"Lead_Source": "Web"}.`); + } +} + +function listItems(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value.flatMap((item) => { + if (Array.isArray(item)) { + return item; + } + return typeof item === 'string' ? jsonList(item) ?? [item] : [item]; + }); + } + const text = String(value); + return jsonList(text) ?? text.split(','); +} + +function jsonList(text: string): unknown[] | undefined { + const trimmed = text.trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function stringList(value: unknown): string[] { + if (value === undefined || value === null || value === '') { + return []; + } + return listItems(value) + .map((v) => (typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '')) + .filter((v) => v.length > 0); +} + +export const WORKFLOW_TRIGGERS = ['workflow', 'approval', 'blueprint', 'pathfinder', 'orchestration'] as const; + +const WORKFLOW_TRIGGER_SET = new Set(WORKFLOW_TRIGGERS); + +export function parseTriggers({ value, skipAll }: { value: unknown; skipAll?: boolean }): string[] | undefined { + if (skipAll === true) { + return []; + } + const list = stringList(value); + if (list.length === 0) { + return undefined; + } + const bad = list.filter((t) => !WORKFLOW_TRIGGER_SET.has(t)); + if (bad.length > 0) { + throw new ZohoCrmError(`Unknown automation trigger(s): ${bad.join(', ')}. Allowed: ${WORKFLOW_TRIGGERS.join(', ')}.`); + } + return list; +} + +type ZohoErrorBody = { + code?: string; + message?: string; + status?: string; + details?: Record; +}; + +export type ZohoRequest = { + auth: ZohoAuth; + method: HttpMethod; + path: string; + version?: string; + query?: QueryParams; + body?: unknown; + headers?: Record; + responseType?: HttpRequest['responseType']; +}; + +export type ZohoWriteItem = { + code?: string; + status?: string; + message?: string; + action?: string; + duplicate_field?: string | null; + details?: Record; +}; + +export type ZohoListInfo = { + per_page?: number; + count?: number; + page?: number; + more_records?: boolean; + next_page_token?: string | null; + previous_page_token?: string | null; + page_token_expiry?: string | null; +}; + +export type ZohoListResponse = { data?: T[]; info?: ZohoListInfo }; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/download.ts b/packages/pieces/community/zoho-crm/src/lib/common/download.ts new file mode 100644 index 000000000000..9f2b774b4bce --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/download.ts @@ -0,0 +1,175 @@ +import { Readable, Transform, pipeline } from 'node:stream'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { FilesService } from '@activepieces/pieces-framework'; +import { ZohoAuth, ZohoCrmError, authHeaders, getApiDomain, toZohoError } from './client'; +import { TokenHosts, tokenHosts, zohoTokenUrl } from './host-guard'; + +export async function downloadToFile({ + auth, + url, + files, + fileName, + fallbackName, +}: { + auth: ZohoAuth; + url: string; + files: FilesService; + fileName?: string; + fallbackName?: string; +}): Promise { + const hosts = tokenHosts(getApiDomain(auth)); + const first = zohoTokenUrl({ raw: url, hosts }); + if (!first) { + throw new ZohoCrmError( + `Refusing to send the Zoho CRM token to "${url}": the URL must be an https link to this connection's Zoho API (https://${hosts.apiHost}/crm/...)${ + hosts.downloadHosts.length > 0 ? ` or its download host (https://${hosts.downloadHosts[0]}/...)` : '' + }.`, + ); + } + const response = await openDownload({ url: first, hosts, accessToken: auth.access_token }); + const name = fileName ?? fileNameFromDisposition({ header: response.headers['content-disposition'], fallback: fallbackName ?? lastPathSegment(first) }); + const counted = countBytes(response.body); + const file = await files.write({ fileName: name, data: counted.stream }).catch((error: unknown) => { + counted.stream.destroy(); + response.body.destroy(); + throw error; + }); + const contentType = response.headers['content-type']; + return { file, fileName: name, size: counted.total(), contentType: typeof contentType === 'string' ? contentType : null }; +} + +export function fileNameFromDisposition({ header, fallback }: { header: unknown; fallback: string }): string { + const value = Array.isArray(header) ? header[0] : header; + if (typeof value !== 'string') return fallback; + const star = /filename\*=(?:[\w!#$%&+^`{}~-]+'[\w-]*')?([^;]+)/i.exec(value); + const plain = /filename="?([^";]+)"?/i.exec(value); + const raw = star ? safeDecode(star[1].trim().replace(/^"|"$/g, '')) : plain ? plain[1].trim() : undefined; + return (raw === undefined ? undefined : safeFileName(raw)) ?? fallback; +} + +export function lastPathSegment(url: URL): string { + const last = url.pathname.split('/').filter((part) => part.length > 0).pop(); + return (last === undefined ? undefined : safeFileName(safeDecode(last))) ?? 'zoho-file'; +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function safeFileName(raw: string): string | undefined { + const base = raw.split(/[\\/]/).pop() ?? ''; + const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, '').trim(); + return cleaned.length === 0 || /^\.+$/.test(cleaned) ? undefined : cleaned.slice(0, 200); +} + +async function openDownload({ url, hosts, accessToken }: { url: URL; hosts: TokenHosts; accessToken: string }): Promise { + let current = url; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const withToken = zohoTokenUrl({ raw: current.toString(), hosts }) !== null; + let response; + try { + response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: current.toString(), + headers: withToken ? authHeaders({ access_token: accessToken }) : {}, + responseType: 'stream', + followRedirects: false, + timeout: REQUEST_TIMEOUT_MS, + }); + } catch (error) { + throw toZohoError(error); + } + const body = toReadable(response.body); + const headers = response.headers ?? {}; + if (response.status >= 300 && response.status < 400) { + body.destroy(); + current = redirectTarget({ location: headers['location'], base: current, status: response.status, hosts }); + continue; + } + if (response.status === 204) { + body.destroy(); + throw new ZohoCrmError('Zoho returned no file content (a link attachment has no file to download).'); + } + return { body, headers }; + } + throw new ZohoCrmError(`The download was redirected more than ${MAX_REDIRECTS} times, so it was stopped.`); +} + +function redirectTarget({ location, base, status, hosts }: { location: unknown; base: URL; status: number; hosts: TokenHosts }): URL { + const value = Array.isArray(location) ? location[0] : location; + if (typeof value !== 'string' || value.length === 0) { + throw new ZohoCrmError(`Zoho answered HTTP ${status} without a Location header.`); + } + let target: URL; + try { + target = new URL(value, base); + } catch { + throw new ZohoCrmError(`Zoho redirected the download to an address that is not a valid link.`); + } + if (target.protocol !== 'https:') { + throw new ZohoCrmError(`Zoho redirected the download to a non-https address, so it was stopped.`); + } + if (!isFileHost({ url: target, hosts })) { + throw new ZohoCrmError(`Zoho redirected the download to ${target.host}, which is not one of this connection's Zoho CRM file hosts, so it was stopped.`); + } + return target; +} + +function isFileHost({ url, hosts }: { url: URL; hosts: TokenHosts }): boolean { + const host = url.hostname.toLowerCase().replace(/\.$/, ''); + const standardPort = url.port === '' || url.port === '443'; + const noUserInfo = url.username === '' && url.password === ''; + return standardPort && noUserInfo && (host === hosts.apiHost || hosts.downloadHosts.includes(host)); +} + +function toReadable(body: Readable | Buffer | undefined): Readable { + if (body === undefined) return Readable.from([]); + if (Buffer.isBuffer(body)) return Readable.from([body]); + return body; +} + +function countBytes(body: Readable): { stream: Readable; total: () => number } { + let total = 0; + let idleTimer: NodeJS.Timeout | undefined; + const clearIdleTimer = (): void => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + }; + const counter = new Transform({ + transform(chunk: unknown, _encoding, callback) { + armIdleTimer(); + const bytes = Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk) : Buffer.from(String(chunk)); + total += bytes.length; + callback(null, bytes); + }, + flush(callback) { + clearIdleTimer(); + callback(); + }, + destroy(error, callback) { + clearIdleTimer(); + callback(error); + }, + }); + const armIdleTimer = (): void => { + clearIdleTimer(); + idleTimer = setTimeout(() => counter.destroy(new ZohoCrmError(`The download stalled for ${IDLE_TIMEOUT_MS / 1000} seconds.`)), IDLE_TIMEOUT_MS); + }; + armIdleTimer(); + const stream = pipeline(body, counter, () => clearIdleTimer()); + return { stream, total: () => total }; +} + +const MAX_REDIRECTS = 5; +const REQUEST_TIMEOUT_MS = 60_000; +const IDLE_TIMEOUT_MS = 60_000; + +type OpenedDownload = { body: Readable; headers: Record }; + +export type DownloadedFile = { file: string; fileName: string; size: number; contentType: string | null }; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/fields.ts b/packages/pieces/community/zoho-crm/src/lib/common/fields.ts new file mode 100644 index 000000000000..ac1af66eda97 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/fields.ts @@ -0,0 +1,241 @@ +import { InputPropertyMap, Property } from '@activepieces/pieces-framework'; +import { ZohoCrmError, parseJsonObject, requireId, stringList } from './client'; +import { ZohoField } from './metadata'; + +const SKIPPED_TYPES = new Set([ + 'subform', + 'fileupload', + 'imageupload', + 'profileimage', + 'formula', + 'autonumber', + 'event_reminder', + 'RRULE', + 'ALARM', + 'multiselectlookup', + 'multiuserlookup', + 'consent_lookup', + 'rollup_summary', +]); + +const NUMBER_TYPES = new Set(['integer', 'double', 'decimal', 'currency', 'percent']); +const BIG_INTEGER_TYPES = new Set(['bigint', 'long']); +const LOOKUP_TYPES = new Set(['lookup', 'ownerlookup', 'userlookup']); +const SYSTEM_FIELDS = new Set(['id', 'Created_Time', 'Modified_Time', 'Created_By', 'Modified_By', 'Last_Activity_Time', 'Tag']); + +export function isApiReadOnly(field: ZohoField): boolean { + if (field.read_only === true) return true; + const apiWritableOwner = field.data_type === 'ownerlookup' && field.api_name === 'Owner'; + return field.field_read_only === true && !apiWritableOwner; +} + +function isWritableField(field: ZohoField): boolean { + if (!field.api_name || SYSTEM_FIELDS.has(field.api_name) || field.api_name.startsWith('$')) { + return false; + } + if (isApiReadOnly(field) || field.visible === false) { + return false; + } + return !SKIPPED_TYPES.has(field.data_type ?? ''); +} + +function labelOf(field: ZohoField): string { + return field.display_label ?? field.field_label ?? field.api_name; +} + +function picklistOptions(field: ZohoField): { label: string; value: string }[] { + return (field.pick_list_values ?? []).flatMap((p) => { + const value = p.actual_value; + if (typeof value !== 'string' || value === '-None-') { + return []; + } + return [{ label: p.display_value ?? value, value }]; + }); +} + +export function buildFieldProps({ fields, mode }: { fields: ZohoField[]; mode: FieldMode }): InputPropertyMap { + const props: InputPropertyMap = {}; + for (const field of fields.filter(isWritableField)) { + const required = mode === 'create' && field.system_mandatory === true; + const displayName = labelOf(field); + const type = field.data_type ?? 'text'; + const base = `API name: ${field.api_name}.`; + if (NUMBER_TYPES.has(type)) { + props[field.api_name] = Property.Number({ displayName, description: base, required }); + } else if (BIG_INTEGER_TYPES.has(type)) { + props[field.api_name] = Property.ShortText({ displayName, description: `${base} Whole number, up to 18 digits.`, required }); + } else if (type === 'boolean') { + props[field.api_name] = Property.StaticDropdown({ + displayName, + description: `${base} Leave empty to ${mode === 'update' ? 'keep the current value' : 'use the Zoho default'}.`, + required, + options: { options: [{ label: 'Yes', value: 'true' }, { label: 'No', value: 'false' }] }, + }); + } else if (type === 'date') { + props[field.api_name] = Property.ShortText({ displayName, description: `${base} Date as yyyy-MM-dd, e.g. 2026-10-01.`, required }); + } else if (type === 'datetime') { + props[field.api_name] = Property.DateTime({ displayName, description: `${base} Sent as ISO 8601 with offset.`, required }); + } else if (type === 'picklist') { + props[field.api_name] = Property.StaticDropdown({ + displayName, + description: base, + required, + options: { options: picklistOptions(field) }, + }); + } else if (type === 'multiselectpicklist') { + props[field.api_name] = Property.StaticMultiSelectDropdown({ + displayName, + description: `${base} Selected values replace the current ones.`, + required, + options: { options: picklistOptions(field) }, + }); + } else if (LOOKUP_TYPES.has(type)) { + const target = type === 'lookup' ? field.lookup?.module?.api_name ?? 'the related module' : 'Users'; + props[field.api_name] = Property.ShortText({ + displayName, + description: `${base} Id of a record in ${target}.`, + required, + }); + } else if (type === 'textarea') { + props[field.api_name] = Property.LongText({ displayName, description: base, required }); + } else { + props[field.api_name] = Property.ShortText({ displayName, description: base, required }); + } + } + return props; +} + +export function fieldsJsonFallbackProp({ reason, mode }: { reason: string; mode: FieldMode }): InputPropertyMap { + return { + [FIELDS_JSON_KEY]: Property.Json({ + displayName: 'Fields', + description: `The field list could not be loaded (${reason}). Enter a JSON object of field API names and values, e.g. {"Last_Name": "Doe", "Email": "jane@example.com"}.`, + required: mode === 'create', + defaultValue: {}, + }), + }; +} + +function isBlank(value: unknown): boolean { + return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); +} + +function toDate({ value, api }: { value: unknown; api: string }): string { + const match = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(String(value).trim()); + const date = match ? new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))) : undefined; + if (!match || !date || date.getUTCMonth() !== Number(match[2]) - 1 || date.getUTCDate() !== Number(match[3])) { + throw new ZohoCrmError(`${api} must be a date as yyyy-MM-dd, e.g. 2026-10-01.`); + } + return `${match[1]}-${match[2]}-${match[3]}`; +} + +function toBigInteger({ value, api }: { value: unknown; api: string }): string { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) { + throw new ZohoCrmError(`${api} arrived as a number above 2^53, which is already rounded; pass long integers above 2^53 as text (the exact digits).`); + } + return String(value); + } + const digits = String(value).trim(); + if (!/^-?\d{1,18}$/.test(digits)) { + throw new ZohoCrmError(`${api} must be a whole number of at most 18 digits.`); + } + return digits; +} + +function toDateTime({ value, api }: { value: unknown; api: string }): string { + const s = String(value); + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(s)) { + return s; + } + const d = new Date(s); + if (Number.isNaN(d.getTime())) { + throw new ZohoCrmError(`${api} must be an ISO 8601 date-time such as 2026-10-01T09:00:00+02:00.`); + } + return d.toISOString().replace(/\.\d{3}Z$/, '+00:00'); +} + +function toBoolean({ value, api }: { value: unknown; api: string }): boolean { + if (value === true || value === 'true') return true; + if (value === false || value === 'false') return false; + throw new ZohoCrmError(`${api} must be true or false.`); +} + +function toNumber({ value, api }: { value: unknown; api: string }): number { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) { + throw new ZohoCrmError(`${api} must be a number.`); + } + if (typeof value === 'string' && !isExactDecimal({ text: value.trim(), n })) { + throw new ZohoCrmError(`${api} has more digits than can be sent exactly (${value.trim()}); use at most 15 significant digits.`); + } + return n; +} + +function isExactDecimal({ text, n }: { text: string; n: number }): boolean { + const match = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(text); + if (!match) { + return true; + } + const whole = match[2].replace(/^0+/, ''); + const fraction = (match[3] ?? '').replace(/0+$/, ''); + if ((whole + fraction).replace(/^0+/, '').length <= MAX_EXACT_DIGITS) { + return true; + } + const canonical = `${match[1] === '-' ? '-' : ''}${whole || '0'}${fraction ? `.${fraction}` : ''}`; + return String(n) === canonical; +} + +const MAX_EXACT_DIGITS = 15; + +export function convertFieldValue({ field, value }: { field: ZohoField | undefined; value: unknown }): unknown { + if (!field) { + return value; + } + const type = field.data_type ?? ''; + const api = field.api_name; + if (NUMBER_TYPES.has(type)) return toNumber({ value, api }); + if (BIG_INTEGER_TYPES.has(type)) return toBigInteger({ value, api }); + if (type === 'boolean') return toBoolean({ value, api }); + if (type === 'date') return toDate({ value, api }); + if (type === 'datetime') return toDateTime({ value, api }); + if (type === 'multiselectpicklist') { + return stringList(value); + } + if (LOOKUP_TYPES.has(type)) { + if (typeof value === 'object' && value !== null) return value; + return { id: requireId({ value, name: api }) }; + } + return value; +} + +export function buildRecordPayload({ + dynamicValues, + fields, + extraFields, + clearFields, +}: { + dynamicValues: Record | undefined; + fields: ZohoField[] | undefined; + extraFields?: unknown; + clearFields?: string[]; +}): Record { + const byName = new Map((fields ?? []).map((f) => [f.api_name, f])); + const { [FIELDS_JSON_KEY]: fallback, ...values } = dynamicValues ?? {}; + const formValues = Object.fromEntries( + Object.entries(values) + .filter(([, value]) => !isBlank(value)) + .map(([key, value]) => [key, convertFieldValue({ field: byName.get(key), value })]), + ); + const cleared = Object.fromEntries((clearFields ?? []).map((key) => [key, null])); + return { + ...parseJsonObject({ value: extraFields, name: 'Additional fields' }), + ...parseJsonObject({ value: fallback, name: 'Fields' }), + ...formValues, + ...cleared, + }; +} + +export const FIELDS_JSON_KEY = '__fields_json'; + +export type FieldMode = 'create' | 'update' | 'upsert'; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/flatten.ts b/packages/pieces/community/zoho-crm/src/lib/common/flatten.ts new file mode 100644 index 000000000000..e9af7f59bc70 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/flatten.ts @@ -0,0 +1,111 @@ +import { isRecord } from './client'; + +function obj(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function str(value: unknown): string | null { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return null; +} + +function emailOf(value: unknown): string | null { + if (typeof value === 'string') return value; + return str(obj(value)['email']); +} + +function emailList(value: unknown): string | null { + if (!Array.isArray(value)) return null; + const list = value.map(emailOf).filter((v): v is string => v !== null); + return list.length > 0 ? list.join(', ') : null; +} + +export function flattenUser(u: Record): Record { + const role = obj(u['role']); + const profile = obj(u['profile']); + return { + id: str(u['id']), + full_name: str(u['full_name']), + first_name: str(u['first_name']), + last_name: str(u['last_name']), + email: str(u['email']), + status: str(u['status']), + role_id: str(role['id']), + role_name: str(role['name']), + profile_id: str(profile['id']), + profile_name: str(profile['name']), + time_zone: str(u['time_zone']), + confirm: typeof u['confirm'] === 'boolean' ? u['confirm'] : null, + }; +} + +export function flattenNote(n: Record): Record { + const parent = obj(n['Parent_Id']); + const parentModule = obj(parent['module']); + const owner = obj(n['Owner']); + return { + id: str(n['id']), + title: str(n['Note_Title']), + content: str(n['Note_Content']), + parent_id: str(parent['id']), + parent_name: str(parent['name']), + parent_module: str(parentModule['api_name']) ?? str(n['$se_module']), + owner_id: str(owner['id']), + owner_name: str(owner['name']), + created_time: str(n['Created_Time']), + modified_time: str(n['Modified_Time']), + }; +} + +export function flattenAttachment(a: Record): Record { + const owner = obj(a['Owner']); + const createdBy = obj(a['Created_By']); + const size = a['Size']; + return { + id: str(a['id']), + file_name: str(a['File_Name']), + size: typeof size === 'number' ? size : typeof size === 'string' && size !== '' ? Number(size) : null, + file_id: str(a['$file_id']), + type: str(a['$type']), + link_url: str(a['$link_url']), + created_time: str(a['Created_Time']), + modified_time: str(a['Modified_Time']), + owner_name: str(owner['name']), + created_by_name: str(createdBy['name']), + parent_module: str(a['$se_module']), + }; +} + +export function flattenDraft(d: Record): Record { + const schedule = obj(d['schedule_details']); + const attachments = Array.isArray(d['attachments']) ? d['attachments'] : []; + return { + id: str(d['id']), + subject: str(d['subject']), + from: emailOf(d['from']), + to: emailList(d['to']), + cc: emailList(d['cc']), + bcc: emailList(d['bcc']), + reply_to: emailOf(d['reply_to']), + summary: str(d['summary']), + content: str(d['content']), + rich_text: typeof d['rich_text'] === 'boolean' ? d['rich_text'] : null, + scheduled_time: str(schedule['time']), + attachment_count: attachments.length, + created_time: str(d['created_time']), + modified_time: str(d['modified_time']), + }; +} + +export function flattenRelatedList(r: Record): Record { + return { + api_name: str(r['api_name']), + display_label: str(r['display_label']), + module_api_name: str(obj(r['module'])['api_name']), + type: str(r['type']), + href: str(r['href']), + status: str(r['status']), + id: str(r['id']), + }; +} diff --git a/packages/pieces/community/zoho-crm/src/lib/common/host-guard.ts b/packages/pieces/community/zoho-crm/src/lib/common/host-guard.ts new file mode 100644 index 000000000000..e9102a1fe232 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/host-guard.ts @@ -0,0 +1,50 @@ +export function tokenHosts(apiDomain: string): TokenHosts { + const apiHost = hostOf(apiDomain); + const dataCentre = /^(?:www\.)?zohoapis\.(.+)$/.exec(apiHost)?.[1]; + const zohoDomains = dataCentre === undefined ? [] : dataCentre === 'ca' ? ['zohocloud.ca'] : [`zoho.${dataCentre}`]; + return { + apiHost, + downloadHosts: zohoDomains.flatMap((domain) => [`download-accl.${domain}`, `download.${domain}`]), + }; +} + +export function zohoTokenUrl({ raw, hosts }: { raw: string; hosts: TokenHosts }): URL | null { + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') return null; + if (url.port !== '' && url.port !== '443') return null; + const host = url.hostname.toLowerCase().replace(/\.$/, ''); + const path = decodedPath(url.pathname); + if (path === null || FUNCTIONS_PATH.test(path)) return null; + if (host === hosts.apiHost) { + return path.startsWith('/crm/') ? url : null; + } + if (hosts.downloadHosts.includes(host)) { + return path.startsWith('/crm/') || path.startsWith('/v2/crm/') ? url : null; + } + return null; +} + +function hostOf(apiDomain: string): string { + try { + return new URL(apiDomain).hostname.toLowerCase(); + } catch { + return ''; + } +} + +function decodedPath(pathname: string): string | null { + try { + return decodeURIComponent(pathname).toLowerCase(); + } catch { + return null; + } +} + +const FUNCTIONS_PATH = /\/functions(\/|$)/; + +export type TokenHosts = { apiHost: string; downloadHosts: string[] }; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/metadata.ts b/packages/pieces/community/zoho-crm/src/lib/common/metadata.ts new file mode 100644 index 000000000000..44ca641afc19 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/metadata.ts @@ -0,0 +1,89 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { ZohoAuth, zohoRequest } from './client'; + +export async function listModules(auth: ZohoAuth): Promise { + const body = await zohoRequest<{ modules?: ZohoModule[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/modules', + }); + return body?.modules ?? []; +} + +export async function listFields({ auth, module }: { auth: ZohoAuth; module: string }): Promise { + const body = await zohoRequest<{ fields?: ZohoField[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/fields', + query: { module }, + }); + return body?.fields ?? []; +} + +const LOW_VALUE_FIELDS = new Set(['Latitude', 'Longitude', 'Coordinates', 'Record_Image']); + +function isLowValue(field: ZohoField): boolean { + return LOW_VALUE_FIELDS.has(field.api_name) || field.api_name.endsWith('__s') || field.api_name.startsWith('$'); +} + +export function defaultFieldSelection({ fields, max = 50 }: { fields: ZohoField[]; max?: number }): string[] { + const readable = fields.filter((f) => f.visible !== false && f.api_name && f.api_name !== 'id'); + const custom = readable.filter((f) => f.custom_field === true && !isLowValue(f)); + const standard = readable.filter((f) => f.custom_field !== true && !isLowValue(f)); + const low = readable.filter((f) => isLowValue(f)); + return ['id', ...[...custom, ...standard, ...low].map((f) => f.api_name)].slice(0, max); +} + +const NAME_FIELDS = [ + 'Full_Name', + 'Account_Name', + 'Deal_Name', + 'Subject', + 'Event_Title', + 'Product_Name', + 'Campaign_Name', + 'Solution_Title', + 'Vendor_Name', + 'Price_Book_Name', + 'Name', + 'Last_Name', +]; + +export function nameFieldOf(fields: ZohoField[]): string | undefined { + const names = new Set(fields.map((f) => f.api_name)); + return NAME_FIELDS.find((n) => names.has(n)); +} + +export type ZohoModule = { + api_name: string; + module_name?: string; + plural_label?: string; + singular_label?: string; + api_supported?: boolean; + creatable?: boolean; + editable?: boolean; + deletable?: boolean; + viewable?: boolean; + generated_type?: string; + id?: string; +}; + +export type ZohoPicklistValue = { display_value?: string; actual_value?: string }; + +export type ZohoField = { + api_name: string; + id?: string; + field_label?: string; + display_label?: string; + data_type?: string; + json_type?: string; + system_mandatory?: boolean; + read_only?: boolean; + field_read_only?: boolean; + visible?: boolean; + custom_field?: boolean; + length?: number; + pick_list_values?: ZohoPicklistValue[]; + lookup?: { module?: { api_name?: string } } | null; + unique?: unknown; +}; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/polling.ts b/packages/pieces/community/zoho-crm/src/lib/common/polling.ts new file mode 100644 index 000000000000..b8c71088854d --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/polling.ts @@ -0,0 +1,220 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { Store } from '@activepieces/pieces-framework'; +import { ZohoAuth, ZohoCrmError, ZohoListResponse, zohoRequestRaw } from './client'; + +export const CURSOR_KEY = 'zoho_poll_cursor'; + +export async function collectSince({ + cursor, + fetchPage, + timeField, + maxPages = MAX_POLL_PAGES, + maxMarkIds = MAX_MARK_IDS, +}: { + cursor: PollCursor; + fetchPage: PageFetcher; + timeField: string; + maxPages?: number; + maxMarkIds?: number; +}): Promise<{ records: ZohoRecord[]; cursor: PollCursor; truncated: boolean }> { + const backlog = cursor.backlog; + const seenAtCheckpoint = new Set(cursor.ids); + const floorIds = new Set(backlog?.floor.ids ?? []); + const fresh = new Map(); + let pageToken = backlog?.pageToken; + let reachedCheckpoint = false; + let capturedAt: number | undefined; + for (let page = 0; page < maxPages && !reachedCheckpoint; page++) { + const result = page === 0 ? await fetchFirstPage({ fetchPage, pageToken }) : await fetchPage({ pageToken }); + capturedAt = capturedAt ?? result.serverTime; + for (const record of result.records) { + const time = recordTime({ record, timeField }); + const id = String(record.id ?? ''); + if (time < cursor.time) { + reachedCheckpoint = true; + break; + } + const alreadyEmitted = + (time === cursor.time && seenAtCheckpoint.has(id)) || + (backlog !== undefined && (time > backlog.floor.time || (time === backlog.floor.time && floorIds.has(id)))); + if (!alreadyEmitted && !fresh.has(id)) { + fresh.set(id, record); + } + } + if (!result.more) { + reachedCheckpoint = true; + } + if (!reachedCheckpoint) { + if (!result.nextPageToken) { + throw new ZohoCrmError('Zoho CRM reported more records but sent no next_page_token, so the trigger cannot page back to its last check. It will retry on the next poll.'); + } + pageToken = result.nextPageToken; + } + } + const records = [...fresh.values()].sort((a, b) => recordTime({ record: a, timeField }) - recordTime({ record: b, timeField })); + return { + records, + cursor: nextCursor({ cursor, records, timeField, truncated: !reachedCheckpoint, pageToken, maxMarkIds, capturedAt }), + truncated: !reachedCheckpoint, + }; +} + +export function modulePageFetcher({ + auth, + module, + fields, + sortBy, + apiVersion, +}: { + auth: ZohoAuth; + module: string; + fields: string[]; + sortBy: 'Created_Time' | 'Modified_Time'; + apiVersion?: string; +}): PageFetcher { + return async ({ pageToken }) => { + const { status, body, headers } = await zohoRequestRaw>({ + auth, + version: apiVersion, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}`, + query: { + fields: fields.join(','), + sort_by: sortBy, + sort_order: 'desc', + per_page: '200', + ...(pageToken ? { page_token: pageToken } : { page: '1' }), + }, + }); + const list = status === 204 ? undefined : body; + return { + records: list?.data ?? [], + more: list?.info?.more_records === true, + nextPageToken: list?.info?.next_page_token ?? undefined, + serverTime: serverTimeOf(headers), + }; + }; +} + +export async function zohoNow({ auth, module }: { auth: ZohoAuth; module: string }): Promise { + const { headers } = await zohoRequestRaw({ + auth, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}`, + query: { fields: 'id', per_page: '1' }, + }); + return serverTimeOf(headers) ?? Date.now(); +} + +export async function initCursor({ store, isRepublish, now }: { store: Store; isRepublish?: boolean; now: () => Promise }): Promise { + if (isRepublish && (await store.get(CURSOR_KEY))) { + return; + } + await store.put(CURSOR_KEY, startCursor(await now())); +} + +export function startCursor(now: number): PollCursor { + return { time: Math.floor(now / 1000) * 1000, ids: [] }; +} + +const MAX_POLL_PAGES = 25; +const MAX_MARK_IDS = 5000; + +function recordTime({ record, timeField }: { record: ZohoRecord; timeField: string }): number { + const raw = record[timeField]; + const t = typeof raw === 'string' ? Date.parse(raw) : NaN; + return Number.isNaN(t) ? 0 : t; +} + +async function fetchFirstPage({ fetchPage, pageToken }: { fetchPage: PageFetcher; pageToken?: string }): Promise { + if (pageToken === undefined) { + return fetchPage({}); + } + try { + return await fetchPage({ pageToken }); + } catch (error) { + if (error instanceof ZohoCrmError && error.status === 400) { + return fetchPage({}); + } + throw error; + } +} + +function idsAt({ records, time, timeField }: { records: ZohoRecord[]; time: number; timeField: string }): string[] { + return records.filter((r) => recordTime({ record: r, timeField }) === time).map((r) => String(r.id ?? '')); +} + +function nextCursor({ + cursor, + records, + timeField, + truncated, + pageToken, + maxMarkIds, + capturedAt, +}: { + cursor: PollCursor; + records: ZohoRecord[]; + timeField: string; + truncated: boolean; + pageToken?: string; + maxMarkIds: number; + capturedAt?: number; +}): PollCursor { + const backlog = cursor.backlog; + const newest = records.length > 0 ? recordTime({ record: records[records.length - 1], timeField }) : undefined; + const oldest = records.length > 0 ? recordTime({ record: records[0], timeField }) : undefined; + const head: PollMark = + newest === undefined + ? { time: cursor.time, ids: cursor.ids } + : { + time: newest, + ids: newest === cursor.time ? [...cursor.ids, ...idsAt({ records, time: newest, timeField })] : idsAt({ records, time: newest, timeField }), + ...(capturedAt !== undefined ? { capturedAt } : {}), + }; + if (truncated && pageToken) { + const top = backlog?.top ?? settleMark({ mark: head, maxMarkIds }); + const floorIds = + oldest === undefined + ? backlog?.floor.ids ?? top.ids + : [...(backlog?.floor.time === oldest ? backlog.floor.ids : []), ...idsAt({ records, time: oldest, timeField })]; + const floor: PollMark = { time: oldest ?? backlog?.floor.time ?? top.time, ids: floorIds.slice(-maxMarkIds) }; + return { time: cursor.time, ids: cursor.ids, backlog: { pageToken, top, floor } }; + } + if (backlog) { + const atTop = [ + ...backlog.top.ids, + ...(backlog.floor.time === backlog.top.time ? backlog.floor.ids : []), + ...idsAt({ records, time: backlog.top.time, timeField }), + ]; + return settleMark({ mark: { ...backlog.top, ids: [...new Set(atTop)] }, maxMarkIds }); + } + return newest === undefined ? cursor : settleMark({ mark: head, maxMarkIds }); +} + +function settleMark({ mark, maxMarkIds }: { mark: PollMark; maxMarkIds: number }): PollMark { + if (mark.ids.length <= maxMarkIds) { + return mark; + } + if (mark.capturedAt !== undefined && mark.time + 1000 <= mark.capturedAt) { + return { time: mark.time + 1000, ids: [] }; + } + return { ...mark, ids: mark.ids.slice(-maxMarkIds) }; +} + +function serverTimeOf(headers: Record): number | undefined { + const value = headers['date']; + const raw = Array.isArray(value) ? value[0] : value; + const time = typeof raw === 'string' ? Date.parse(raw) : NaN; + return Number.isNaN(time) ? undefined : time; +} + +type PollMark = { time: number; ids: string[]; capturedAt?: number }; + +type PageResult = { records: ZohoRecord[]; more: boolean; nextPageToken?: string; serverTime?: number }; + +export type ZohoRecord = Record & { id?: string }; + +export type PollCursor = PollMark & { backlog?: { pageToken: string; top: PollMark; floor: PollMark } }; + +export type PageFetcher = (request: { pageToken?: string }) => Promise; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/props.ts b/packages/pieces/community/zoho-crm/src/lib/common/props.ts new file mode 100644 index 000000000000..86d347ae41ae --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/props.ts @@ -0,0 +1,273 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { InputPropertyMap, Property } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { ZohoAuth, ZohoListResponse, errorText, readField, readString, zohoRequest } from './client'; +import { FieldMode, buildFieldProps, fieldsJsonFallbackProp } from './fields'; +import { listFields, listModules, nameFieldOf } from './metadata'; + +export function moduleDropdown({ + displayName = 'Module', + description = 'The Zoho CRM module (for example Leads, Contacts, Deals or a custom module).', + filter = 'any', + required = true, +}: { + displayName?: string; + description?: string; + filter?: ModuleFilter; + required?: boolean; +} = {}) { + return Property.Dropdown({ + auth: zohoCrmAuth, + displayName, + description, + required, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, options: [], placeholder: 'Connect your Zoho CRM account first' }; + } + try { + const modules = await listModules(auth); + return { + disabled: false, + options: modules + .filter((m) => m.api_supported !== false) + .filter((m) => filter === 'any' || m[filter] !== false) + .map((m) => ({ label: m.plural_label ?? m.api_name, value: m.api_name })), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load modules: ${errorText(error)}` }; + } + }, + }); +} + +export function recordDropdown({ + displayName = 'Record', + description = 'Pick one of the 200 most recently modified records, or map a record id from an earlier step.', + moduleProp = 'module', + fixedModule, + required = true, +}: { + displayName?: string; + description?: string; + moduleProp?: string; + fixedModule?: string; + required?: boolean; +} = {}) { + return Property.Dropdown({ + auth: zohoCrmAuth, + displayName, + description, + required, + refreshers: fixedModule ? [] : [moduleProp], + options: async (props) => { + const auth = props.auth; + const module = fixedModule ?? props[moduleProp]; + if (!auth) { + return { disabled: true, options: [], placeholder: 'Connect your Zoho CRM account first' }; + } + if (typeof module !== 'string' || module.length === 0) { + return { disabled: true, options: [], placeholder: 'Select a module first' }; + } + try { + const fields = await listFields({ auth, module }); + const nameField = nameFieldOf(fields); + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/${encodeURIComponent(module)}`, + query: { + fields: nameField ? `id,${nameField}` : 'id', + per_page: '200', + sort_by: 'Modified_Time', + sort_order: 'desc', + }, + }); + const records = body?.data ?? []; + return { + disabled: false, + placeholder: + records.length === 0 + ? 'No records in this module' + : body?.info?.more_records === true + ? `Showing the ${records.length} most recently modified records; map a record id for others.` + : undefined, + options: records.map((r) => { + const id = String(r['id']); + const name = nameField ? r[nameField] : undefined; + return { label: typeof name === 'string' && name.length > 0 ? `${name} (${id})` : id, value: id }; + }), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load records: ${errorText(error)}` }; + } + }, + }); +} + +export function recordFieldsProp({ mode, moduleProp = 'module' }: { mode: FieldMode; moduleProp?: string }) { + return Property.DynamicProperties({ + auth: zohoCrmAuth, + displayName: 'Fields', + description: + mode === 'update' + ? 'Only the fields you fill in are changed. Use "Clear Fields" to blank a field.' + : 'Standard and custom fields of the module. Read-only, formula, subform and file fields are not listed.', + required: mode === 'create', + refreshers: [moduleProp], + props: async (props): Promise => { + const auth = props.auth; + const module = props[moduleProp]; + if (!auth || typeof module !== 'string' || module.length === 0) { + return {}; + } + try { + const fields = await listFields({ auth, module }); + return buildFieldProps({ fields, mode }); + } catch (error) { + return fieldsJsonFallbackProp({ reason: errorText(error), mode }); + } + }, + }); +} + +export function uniqueFieldsDropdown(moduleProp = 'module') { + return Property.MultiSelectDropdown({ + auth: zohoCrmAuth, + displayName: 'Duplicate Check Fields', + description: + 'Fields used to find an existing record. Leave empty to use Zoho\'s default (Email for Leads/Contacts, Account_Name for Accounts, Deal_Name for Deals, Name for custom modules).', + required: false, + refreshers: [moduleProp], + options: async (props) => { + const auth = props.auth; + const module = props[moduleProp]; + if (!auth || typeof module !== 'string' || module.length === 0) { + return { disabled: true, options: [], placeholder: 'Select a module first' }; + } + try { + const fields = await listFields({ auth, module }); + return { + disabled: false, + options: fields + .filter((f) => f.visible !== false && !f.read_only && ['text', 'email', 'phone', 'website', 'integer', 'bigint', 'autonumber'].includes(f.data_type ?? '')) + .map((f) => ({ label: f.display_label ?? f.field_label ?? f.api_name, value: f.api_name })), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load fields: ${errorText(error)}` }; + } + }, + }); +} + +export function tagsDropdown(moduleProp = 'module') { + return Property.MultiSelectDropdown({ + auth: zohoCrmAuth, + displayName: 'Existing Tags', + description: 'Tags already defined for the module.', + required: false, + refreshers: [moduleProp], + options: async (props) => { + const auth = props.auth; + const module = props[moduleProp]; + if (!auth || typeof module !== 'string' || module.length === 0) { + return { disabled: true, options: [], placeholder: 'Select a module first' }; + } + try { + const body = await zohoRequest<{ tags?: { name: string }[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/tags', + query: { module }, + }); + return { + disabled: false, + options: (body?.tags ?? []).map((t) => ({ label: t.name, value: t.name })), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load tags: ${errorText(error)}` }; + } + }, + }); +} + +export function userDropdown({ + displayName, + description, + required = false, +}: { + displayName: string; + description: string; + required?: boolean; +}) { + return Property.Dropdown({ + auth: zohoCrmAuth, + displayName, + description, + required, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, options: [], placeholder: 'Connect your Zoho CRM account first' }; + } + const { users, notice } = await listActiveUsers({ auth }); + if (users.length === 0 && notice) { + return { disabled: true, options: [], placeholder: notice }; + } + return { + disabled: false, + placeholder: notice, + options: users.map((u) => ({ label: u.email ? `${u.name} (${u.email})` : u.name, value: u.id })), + }; + }, + }); +} + +async function listActiveUsers({ auth }: { auth: ZohoAuth }): Promise<{ users: UserOption[]; notice?: string }> { + const users: UserOption[] = []; + for (let page = 1; page <= MAX_USER_PAGES; page++) { + let body: { users?: unknown[]; info?: { more_records?: boolean } } | undefined; + try { + body = await zohoRequest<{ users?: unknown[]; info?: { more_records?: boolean } }>({ + auth, + method: HttpMethod.GET, + path: '/users', + query: { type: 'ActiveUsers', per_page: String(USERS_PER_PAGE), page: String(page) }, + }); + } catch (error) { + const reason = errorText(error); + return { users, notice: users.length === 0 ? `Could not load users: ${reason}` : `Showing the first ${users.length} users; the rest could not be loaded (${reason}).` }; + } + users.push(...(body?.users ?? []).flatMap(toUserOption)); + if (body?.info?.more_records !== true) { + return { users }; + } + } + return { users, notice: `Showing the first ${users.length} active users; map a user id for anyone else.` }; +} + +function toUserOption(user: unknown): UserOption[] { + const id = readString(readField({ value: user, key: 'id' })); + if (!id) { + return []; + } + const name = readString(readField({ value: user, key: 'full_name' })) ?? id; + return [{ id, name, email: readString(readField({ value: user, key: 'email' })) }]; +} + +const USERS_PER_PAGE = 200; +const MAX_USER_PAGES = 10; + +type UserOption = { id: string; name: string; email?: string }; + +export function additionalFieldsProp() { + return Property.Json({ + displayName: 'Additional Fields (JSON)', + description: + 'Optional raw fields by API name, for fields the form does not list (e.g. subform rows). Values set in the form above win over the same key here.', + required: false, + }); +} + +type ModuleFilter = 'any' | 'creatable' | 'editable' | 'deletable'; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/record-trigger.ts b/packages/pieces/community/zoho-crm/src/lib/common/record-trigger.ts new file mode 100644 index 000000000000..9acc9e10e705 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/record-trigger.ts @@ -0,0 +1,149 @@ +import { Property, Store } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { ZohoAuth, ZohoCrmError, errorText, stringList } from './client'; +import { defaultFieldSelection, listFields } from './metadata'; +import { CURSOR_KEY, PollCursor, ZohoRecord, collectSince, initCursor, modulePageFetcher, startCursor, zohoNow } from './polling'; +import { moduleDropdown } from './props'; +import { tryListFields } from './records'; + +export const recordTriggerProps = { + module: moduleDropdown({ description: 'The module to watch (standard or custom).' }), + fields: Property.MultiSelectDropdown({ + auth: zohoCrmAuth, + displayName: 'Fields to Include', + description: + 'Optional, up to 47. Leave empty for id, then custom, then standard fields (50 in total).', + required: false, + refreshers: ['module'], + options: async ({ auth, module }) => { + if (!auth || typeof module !== 'string' || module.length === 0) { + return { disabled: true, options: [], placeholder: 'Select a module first' }; + } + try { + const fields = await listFields({ auth, module }); + return { + disabled: false, + options: fields + .filter((f) => f.visible !== false) + .map((f) => ({ label: f.display_label ?? f.field_label ?? f.api_name, value: f.api_name })), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load fields: ${errorText(error)}` }; + } + }, + }), +}; + +export async function pollModule({ + auth, + store, + module, + fields, + sortBy, + keep, + apiVersion, +}: { + auth: ZohoAuth; + store: Store; + module: string; + fields: string[]; + sortBy: 'Created_Time' | 'Modified_Time'; + keep?: (record: ZohoRecord) => boolean; + apiVersion?: string; +}): Promise { + const cursor = (await store.get(CURSOR_KEY)) ?? startCursor(Date.now()); + const result = await collectSince({ + cursor, + timeField: sortBy, + fetchPage: modulePageFetcher({ auth, module, fields, sortBy, apiVersion }), + }); + await store.put(CURSOR_KEY, result.cursor); + return keep ? result.records.filter(keep) : result.records; +} + +export async function sampleModule({ + auth, + module, + fields, + sortBy, + keep, + apiVersion, +}: { + auth: ZohoAuth; + module: string; + fields: string[]; + sortBy: 'Created_Time' | 'Modified_Time'; + keep?: (record: ZohoRecord) => boolean; + apiVersion?: string; +}): Promise { + const { records } = await modulePageFetcher({ auth, module, fields, sortBy, apiVersion })({}); + return (keep ? records.filter(keep) : records).slice(0, 5); +} + +export async function triggerFields({ + auth, + store, + module, + chosen, +}: { + auth: ZohoAuth; + store?: Store; + module: string; + chosen: unknown; +}): Promise { + const picked = pickedFields(chosen); + if (picked.length > 0) { + return [...new Set([...ALWAYS_INCLUDED, ...picked])]; + } + const cached = store ? await store.get(FIELDS_KEY) : null; + if (cached && cached.module === module && Date.now() - cached.at < FIELDS_CACHE_MS) { + return cached.fields; + } + const available = await tryListFields({ auth, module }); + if (!available) { + throw new ZohoCrmError( + `Zoho CRM did not let this connection read the ${module} field list, so the default fields cannot be chosen. Pick the fields to include in the trigger settings.`, + ); + } + const fields = [...new Set([...ALWAYS_INCLUDED, ...defaultFieldSelection({ fields: available })])].slice(0, MAX_FIELDS); + if (store) { + await store.put(FIELDS_KEY, { module, fields, at: Date.now() }); + } + return fields; +} + +export async function startTrigger({ + auth, + module, + store, + isRepublish, + chosen, +}: { + auth: ZohoAuth; + module: string; + store: Store; + isRepublish?: boolean; + chosen: unknown; +}): Promise { + pickedFields(chosen); + await store.delete(FIELDS_KEY); + await initCursor({ store, isRepublish, now: () => zohoNow({ auth, module }) }); +} + +function pickedFields(chosen: unknown): string[] { + const picked = [...new Set(stringList(chosen).filter((f) => !ALWAYS_INCLUDED.includes(f)))]; + if (picked.length > MAX_PICKED_FIELDS) { + throw new ZohoCrmError( + `Fields to Include has ${picked.length} fields. Zoho returns at most ${MAX_FIELDS} fields per record, and id, Created_Time and Modified_Time are always included, so pick at most ${MAX_PICKED_FIELDS}.`, + ); + } + return picked; +} + +const ALWAYS_INCLUDED = ['id', 'Created_Time', 'Modified_Time']; +const MAX_FIELDS = 50; +const MAX_PICKED_FIELDS = MAX_FIELDS - ALWAYS_INCLUDED.length; +const FIELDS_KEY = 'zoho_poll_fields'; +const FIELDS_CACHE_MS = 6 * 60 * 60 * 1000; + +type CachedFields = { module: string; fields: string[]; at: number }; diff --git a/packages/pieces/community/zoho-crm/src/lib/common/records.ts b/packages/pieces/community/zoho-crm/src/lib/common/records.ts new file mode 100644 index 000000000000..9198d58e5d72 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/common/records.ts @@ -0,0 +1,467 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { + ZohoAuth, + ZohoCrmError, + ZohoListResponse, + ZohoWriteItem, + flattenWriteResult, + isRecord, + readField, + readString, + unwrapWriteItem, + zohoRequest, +} from './client'; +import { ZohoField, listFields } from './metadata'; + +const enc = encodeURIComponent; + +export async function tryListFields({ auth, module }: { auth: ZohoAuth; module: string }): Promise { + try { + return await listFields({ auth, module }); + } catch (error) { + if (error instanceof ZohoCrmError && (error.status === 403 || METADATA_REFUSED_CODES.has(error.code ?? ''))) { + return undefined; + } + throw error; + } +} + +const METADATA_REFUSED_CODES = new Set(['NO_PERMISSION', 'OAUTH_SCOPE_MISMATCH']); + +function ensureExactNumbers({ value, path }: { value: unknown; path: string }): void { + if (typeof value === 'number' && Math.abs(value) > Number.MAX_SAFE_INTEGER) { + throw new ZohoCrmError(`${path} arrived as a number above 2^53, which is already rounded; pass it as text (the exact digits).`); + } + if (Array.isArray(value)) { + value.forEach((item, index) => ensureExactNumbers({ value: item, path: `${path}[${index}]` })); + } else if (isRecord(value)) { + Object.entries(value).forEach(([key, item]) => ensureExactNumbers({ value: item, path: `${path}.${key}` })); + } +} + +function validateRecord({ record, what }: { record: Record; what: string }): void { + Object.entries(record).forEach(([key, item]) => ensureExactNumbers({ value: item, path: key })); + if (Object.keys(record).length === 0) { + throw new ZohoCrmError(`${what}: no fields were provided.`); + } +} + +export async function createRecord({ + auth, + module, + record, + trigger, +}: { + auth: ZohoAuth; + module: string; + record: Record; + trigger?: string[]; +}): Promise> { + validateRecord({ record, what: 'Create record' }); + const body = await zohoRequest({ + auth, + method: HttpMethod.POST, + path: `/${enc(module)}`, + body: { data: [record], ...(trigger !== undefined ? { trigger } : {}) }, + }); + return { module, ...flattenWriteResult(unwrapWriteItem({ body })) }; +} + +export async function updateRecord({ + auth, + module, + id, + record, + trigger, + appendMultiSelect, +}: { + auth: ZohoAuth; + module: string; + id: string; + record: Record; + trigger?: string[]; + appendMultiSelect?: string[]; +}): Promise> { + validateRecord({ record, what: 'Update record' }); + const data: Record = { ...record }; + if (appendMultiSelect && appendMultiSelect.length > 0) { + data['$append_values'] = Object.fromEntries(appendMultiSelect.map((f) => [f, true])); + } + const body = await zohoRequest({ + auth, + method: HttpMethod.PUT, + path: `/${enc(module)}/${enc(id)}`, + body: { data: [data], ...(trigger !== undefined ? { trigger } : {}) }, + }); + return { module, ...flattenWriteResult(unwrapWriteItem({ body })) }; +} + +export async function upsertRecord({ + auth, + module, + record, + duplicateCheckFields, + trigger, +}: { + auth: ZohoAuth; + module: string; + record: Record; + duplicateCheckFields?: string[]; + trigger?: string[]; +}): Promise> { + validateRecord({ record, what: 'Upsert record' }); + const body = await zohoRequest({ + auth, + method: HttpMethod.POST, + path: `/${enc(module)}/upsert`, + body: { + data: [record], + ...(duplicateCheckFields && duplicateCheckFields.length > 0 ? { duplicate_check_fields: duplicateCheckFields } : {}), + ...(trigger !== undefined ? { trigger } : {}), + }, + }); + return { module, ...flattenWriteResult(unwrapWriteItem({ body })) }; +} + +export async function getRecord({ + auth, + module, + id, + fields, +}: { + auth: ZohoAuth; + module: string; + id: string; + fields?: string[]; +}): Promise> { + const body = await zohoRequest>>({ + auth, + method: HttpMethod.GET, + path: `/${enc(module)}/${enc(id)}`, + query: fields && fields.length > 0 ? { fields: fields.join(',') } : undefined, + }); + const record = body?.data?.[0]; + if (!record) { + throw new ZohoCrmError(`No ${module} record with id ${id} was found.`, 404, 'NOT_FOUND'); + } + return record; +} + +export async function deleteRecord({ + auth, + module, + id, + runWorkflows, +}: { + auth: ZohoAuth; + module: string; + id: string; + runWorkflows?: boolean; +}): Promise> { + let body: unknown; + try { + body = await zohoRequest({ + auth, + method: HttpMethod.DELETE, + path: `/${enc(module)}/${enc(id)}`, + query: runWorkflows === false ? { wf_trigger: 'false' } : undefined, + }); + } catch (error) { + throw notDeletedError({ error, what: `${module} record ${id}` }); + } + const item = unwrapWriteItem({ body }); + return { module, deleted: true, ...flattenWriteResult(item), id: readString(item.details?.['id']) ?? id }; +} + +export function notDeletedError({ error, what }: { error: unknown; what: string }): unknown { + if (error instanceof ZohoCrmError && error.code === 'INVALID_DATA') { + return new ZohoCrmError( + `Zoho CRM INVALID_DATA: could not delete ${what}; it does not exist or was already deleted.`, + error.status, + error.code, + ); + } + return error; +} + +export type ConvertLeadInput = { + auth: ZohoAuth; + leadId: string; + accountId?: string; + contactId?: string; + assignTo?: string; + overwrite?: boolean; + notifyLeadOwner?: boolean; + notifyNewEntityOwner?: boolean; + deal?: Record; +}; + +export async function convertLead(input: ConvertLeadInput): Promise> { + const entry: Record = {}; + if (input.overwrite !== undefined) entry['overwrite'] = input.overwrite; + if (input.notifyLeadOwner !== undefined) entry['notify_lead_owner'] = input.notifyLeadOwner; + if (input.notifyNewEntityOwner !== undefined) entry['notify_new_entity_owner'] = input.notifyNewEntityOwner; + if (input.accountId) entry['Accounts'] = { id: input.accountId }; + if (input.contactId) entry['Contacts'] = { id: input.contactId }; + if (input.assignTo) entry['assign_to'] = { id: input.assignTo }; + if (input.deal && Object.keys(input.deal).length > 0) { + Object.entries(input.deal).forEach(([key, item]) => ensureExactNumbers({ value: item, path: `Deal ${key}` })); + entry['Deals'] = input.deal; + } + let item: ZohoWriteItem; + try { + const body = await zohoRequest({ + auth: input.auth, + method: HttpMethod.POST, + path: `/Leads/${enc(input.leadId)}/actions/convert`, + body: { data: [entry] }, + }); + item = unwrapWriteItem({ body }); + } catch (error) { + throw convertMismatchError(error); + } + const d = item.details ?? {}; + const refText = ({ entity, key }: { entity: string; key: string }): string | null => + readString(readField({ value: d[entity], key })) ?? null; + return { + lead_id: input.leadId, + status: item.status ?? null, + message: item.message ?? null, + contact_id: refText({ entity: 'Contacts', key: 'id' }), + contact_name: refText({ entity: 'Contacts', key: 'name' }), + account_id: refText({ entity: 'Accounts', key: 'id' }), + account_name: refText({ entity: 'Accounts', key: 'name' }), + deal_id: refText({ entity: 'Deals', key: 'id' }), + deal_name: refText({ entity: 'Deals', key: 'name' }), + }; +} + +function convertMismatchError(error: unknown): unknown { + if (!(error instanceof ZohoCrmError) || error.code !== 'INVALID_DATA') { + return error; + } + const match = /(Account|Contact) data doesn't match/i.exec(error.message); + if (!match) { + return error; + } + const rule = + match[1].toLowerCase() === 'account' + ? "Zoho links an existing account only when the lead's Company equals the account name" + : "Zoho merges into an existing contact only when the lead's name and email match the contact"; + return new ZohoCrmError( + `Zoho CRM INVALID_DATA: ${match[1]} data doesn't match the lead. ${rule}. Update the lead first, or leave Existing ${match[1]} empty to create a new one.`, + error.status, + error.code, + ); +} + +export async function createNote({ + auth, + module, + recordId, + title, + content, +}: { + auth: ZohoAuth; + module: string; + recordId: string; + title?: string; + content: string; +}): Promise> { + if (!content || content.trim().length === 0) { + throw new ZohoCrmError('Note content is required.'); + } + const note: Record = { + Parent_Id: { module: { api_name: module }, id: recordId }, + Note_Content: content, + }; + if (title && title.length > 0) note['Note_Title'] = title; + const body = await zohoRequest({ + auth, + method: HttpMethod.POST, + path: '/Notes', + body: { data: [note] }, + }); + return { parent_module: module, parent_id: recordId, ...flattenWriteResult(unwrapWriteItem({ body })) }; +} + +export type TagRef = { name?: string; id?: string }; + +export async function changeTags({ + auth, + module, + recordIds, + tags, + mode, +}: { + auth: ZohoAuth; + module: string; + recordIds: string[]; + tags: TagRef[]; + mode: 'add' | 'remove'; +}): Promise> { + if (recordIds.length === 0) throw new ZohoCrmError('At least one record id is required.'); + if (recordIds.length > 500) throw new ZohoCrmError('Zoho accepts at most 500 record ids per call.'); + if (tags.length === 0) throw new ZohoCrmError('At least one tag is required.'); + const body = await zohoRequest<{ data?: { status?: string; code?: string; message?: string; details?: Record }[] }>({ + auth, + method: HttpMethod.POST, + path: `/${enc(module)}/actions/${mode === 'add' ? 'add_tags' : 'remove_tags'}`, + body: { tags, ids: recordIds, ...(mode === 'add' ? { over_write: false } : {}) }, + }); + const rows = (body?.data ?? []).map((r) => ({ + id: readString(r.details?.['id']) ?? null, + status: r.status ?? null, + code: r.code ?? null, + message: r.message ?? null, + tags: tagNames(r.details?.['tags']), + })); + const failed = rows.filter((r) => r.status === 'error'); + if (rows.length > 0 && failed.length === rows.length) { + throw new ZohoCrmError(`Zoho CRM ${failed[0].code}: ${failed[0].message}`); + } + return { module, results: rows, success_count: rows.length - failed.length, failed_count: failed.length }; +} + +export type ZohoTag = { id: string; name: string; color_code?: string | null; created_time?: string; modified_time?: string }; + +export async function listTags({ auth, module }: { auth: ZohoAuth; module: string }): Promise { + const body = await zohoRequest<{ tags?: ZohoTag[] }>({ + auth, + method: HttpMethod.GET, + path: '/settings/tags', + query: { module }, + }); + return body?.tags ?? []; +} + +export const TAG_COLORS = [ + '#F17574', '#F48435', '#E7A826', '#A8C026', '#63C57E', '#1DB9B4', '#57B1FD', + '#879BFC', '#D297EE', '#FD87BD', '#969696', '#658BA8', '#B88562', +]; + +export async function ensureTag({ + auth, + module, + name, + colorCode, +}: { + auth: ZohoAuth; + module: string; + name: string; + colorCode?: string; +}): Promise<{ tag: ZohoTag; created: boolean }> { + const clean = name.trim(); + if (clean.length === 0) throw new ZohoCrmError('Tag name is required.'); + if (colorCode && !TAG_COLORS.includes(colorCode.toUpperCase())) { + throw new ZohoCrmError(`color_code must be one of ${TAG_COLORS.join(', ')}.`); + } + const existing = (await listTags({ auth, module })).find((t) => t.name.toLowerCase() === clean.toLowerCase()); + if (existing) return { tag: existing, created: false }; + const body = await zohoRequest({ + auth, + method: HttpMethod.POST, + path: '/settings/tags', + query: { module }, + body: { tags: [{ name: clean, ...(colorCode ? { color_code: colorCode.toUpperCase() } : {}) }] }, + }); + const item = unwrapWriteItem({ body, key: 'tags' }); + const d = item.details ?? {}; + return { + tag: { id: String(d['id'] ?? ''), name: clean, color_code: colorCode ?? null, created_time: readString(d['created_time']) }, + created: true, + }; +} + +export async function uploadAttachment({ + auth, + module, + recordId, + file, + attachmentUrl, + title, +}: { + auth: ZohoAuth; + module: string; + recordId: string; + file?: { filename: string; data: Buffer }; + attachmentUrl?: string; + title?: string; +}): Promise> { + if ((file ? 1 : 0) + (attachmentUrl ? 1 : 0) !== 1) { + throw new ZohoCrmError('Provide exactly one of a file or an attachment URL.'); + } + const parts: MultipartPart[] = []; + if (file) { + parts.push({ name: 'file', filename: file.filename, data: file.data }); + } else if (attachmentUrl) { + let parsed: URL; + try { + parsed = new URL(attachmentUrl); + } catch { + throw new ZohoCrmError('attachment_url must be a full http(s) URL.'); + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new ZohoCrmError('attachment_url must be a full http(s) URL.'); + } + parts.push({ name: 'attachmentUrl', data: attachmentUrl }); + if (title) parts.push({ name: 'title', data: title }); + } + const multipart = buildMultipart({ parts }); + const body = await zohoRequest({ + auth, + method: HttpMethod.POST, + path: `/${enc(module)}/${enc(recordId)}/Attachments`, + headers: { 'Content-Type': multipart.contentType }, + body: multipart.body, + }); + return { parent_module: module, parent_id: recordId, ...flattenWriteResult(unwrapWriteItem({ body })) }; +} + +export function readApFile(value: unknown): { filename: string; data: Buffer } { + if (!isRecord(value)) { + throw new ZohoCrmError('File is required.'); + } + const data = value['data']; + const rawName = value['filename']; + const base64 = value['base64']; + const filename = typeof rawName === 'string' && rawName.length > 0 ? rawName : 'attachment'; + if (Buffer.isBuffer(data)) return { filename, data }; + if (data instanceof Uint8Array) return { filename, data: Buffer.from(data) }; + if (typeof base64 === 'string') return { filename, data: Buffer.from(base64, 'base64') }; + throw new ZohoCrmError('File could not be read (no data).'); +} + +export function buildMultipart({ + parts, + boundary = `----apZoho${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`, +}: { + parts: MultipartPart[]; + boundary?: string; +}): { body: Buffer; contentType: string } { + const chunks: Buffer[] = []; + for (const part of parts) { + const safeName = (part.filename ?? '').replace(/["\r\n]/g, '_'); + const disposition = part.filename !== undefined + ? `form-data; name="${part.name}"; filename="${safeName}"` + : `form-data; name="${part.name}"`; + const head = `--${boundary}\r\nContent-Disposition: ${disposition}\r\n${part.filename !== undefined ? 'Content-Type: application/octet-stream\r\n' : ''}\r\n`; + chunks.push(Buffer.from(head, 'utf8')); + chunks.push(typeof part.data === 'string' ? Buffer.from(part.data, 'utf8') : part.data); + chunks.push(Buffer.from('\r\n', 'utf8')); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8')); + return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }; +} + +function tagNames(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + return value + .map((t) => readString(readField({ value: t, key: 'name' }))) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + .join(', '); +} + +type MultipartPart = { name: string; data: string | Buffer; filename?: string }; diff --git a/packages/pieces/community/zoho-crm/src/lib/output-schemas-ai.ts b/packages/pieces/community/zoho-crm/src/lib/output-schemas-ai.ts new file mode 100644 index 000000000000..a8032a5cbd3d --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/output-schemas-ai.ts @@ -0,0 +1,246 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; +import { childChangeOutputSchema, childWriteOutputSchema, recordWriteOutputSchema } from './output-schemas'; + +const userFields: OutputSchema['fields'] = [ + { key: 'id', label: 'User ID' }, + { key: 'full_name', label: 'Full Name' }, + { key: 'first_name', label: 'First Name' }, + { key: 'last_name', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'status', label: 'Status' }, + { key: 'role_id', label: 'Role ID' }, + { key: 'role_name', label: 'Role' }, + { key: 'profile_id', label: 'Profile ID' }, + { key: 'profile_name', label: 'Profile' }, + { key: 'time_zone', label: 'Time Zone' }, + { key: 'confirm', label: 'Confirmed', format: 'boolean' }, +]; + +const noteFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Note ID' }, + { key: 'title', label: 'Title' }, + { key: 'content', label: 'Content' }, + { key: 'parent_module', label: 'Parent Module' }, + { key: 'parent_id', label: 'Parent Record ID' }, + { key: 'parent_name', label: 'Parent Record' }, + { key: 'owner_id', label: 'Owner ID' }, + { key: 'owner_name', label: 'Owner' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, +]; + +const attachmentFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Attachment ID' }, + { key: 'file_name', label: 'File Name' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'type', label: 'Type' }, + { key: 'link_url', label: 'Link URL', format: 'url' }, + { key: 'file_id', label: 'File ID' }, + { key: 'parent_module', label: 'Parent Module' }, + { key: 'owner_name', label: 'Owner' }, + { key: 'created_by_name', label: 'Created By' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, +]; + +const draftFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Draft ID' }, + { key: 'subject', label: 'Subject' }, + { key: 'from', label: 'From', format: 'email' }, + { key: 'to', label: 'To' }, + { key: 'cc', label: 'Cc' }, + { key: 'bcc', label: 'Bcc' }, + { key: 'reply_to', label: 'Reply To', format: 'email' }, + { key: 'summary', label: 'Summary' }, + { key: 'content', label: 'Content', format: 'html' }, + { key: 'rich_text', label: 'Rich Text', format: 'boolean' }, + { key: 'scheduled_time', label: 'Scheduled Time', format: 'datetime' }, + { key: 'attachment_count', label: 'Attachments', format: 'number' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, +]; + +const pagingFields: OutputSchema['fields'] = [ + { key: 'count', label: 'Count', format: 'number' }, + { key: 'more_records', label: 'More Records', format: 'boolean' }, +]; + +export const listModulesOutputSchema: OutputSchema = { + fields: [ + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'modules', + label: 'Modules', + labelKey: 'plural_label', + listItems: [ + { key: 'api_name', label: 'API Name' }, + { key: 'module_name', label: 'Module Name' }, + { key: 'plural_label', label: 'Plural Label' }, + { key: 'singular_label', label: 'Singular Label' }, + { key: 'generated_type', label: 'Type' }, + { key: 'creatable', label: 'Creatable', format: 'boolean' }, + { key: 'editable', label: 'Editable', format: 'boolean' }, + { key: 'deletable', label: 'Deletable', format: 'boolean' }, + ], + }, + ], +}; + +export const moduleFieldsOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'fields', + label: 'Fields', + labelKey: 'field_label', + listItems: [ + { key: 'api_name', label: 'API Name' }, + { key: 'field_label', label: 'Label' }, + { key: 'data_type', label: 'Data Type' }, + { key: 'required', label: 'Required on Create', format: 'boolean' }, + { key: 'read_only', label: 'Read Only', format: 'boolean' }, + { key: 'custom_field', label: 'Custom Field', format: 'boolean' }, + { key: 'lookup_module', label: 'Lookup Module' }, + { key: 'max_length', label: 'Max Length', format: 'number' }, + { key: 'picklist_values', label: 'Picklist Values' }, + ], + }, + ], +}; + +export const relatedListsOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'related_lists', + label: 'Related Lists', + labelKey: 'display_label', + listItems: [ + { key: 'api_name', label: 'API Name' }, + { key: 'display_label', label: 'Label' }, + { key: 'module_api_name', label: 'Related Module' }, + { key: 'type', label: 'Type' }, + { key: 'href', label: 'Path' }, + { key: 'status', label: 'Status' }, + ], + }, + ], +}; + +export const listUsersOutputSchema: OutputSchema = { + fields: [...pagingFields, { key: 'users', label: 'Users', labelKey: 'full_name', listItems: userFields }], +}; + +export const userOutputSchema: OutputSchema = { fields: userFields }; + +export const listRecordsOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + ...pagingFields, + { key: 'page', label: 'Page', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + { key: 'fields_requested', label: 'Fields Requested' }, + { key: 'records', label: 'Records' }, + ], +}; + +export const aiWriteOutputSchema: OutputSchema = recordWriteOutputSchema; + +export const aiUpsertOutputSchema: OutputSchema = { + fields: [ + ...recordWriteOutputSchema.fields, + { key: 'action', label: 'Action (insert or update)' }, + { key: 'duplicate_field', label: 'Matched On Field' }, + ], +}; + +export const listNotesOutputSchema: OutputSchema = { + fields: [...pagingFields, { key: 'notes', label: 'Notes', labelKey: 'title', listItems: noteFields }], +}; + +export const noteOutputSchema: OutputSchema = { fields: noteFields }; + +export const noteWriteOutputSchema: OutputSchema = childWriteOutputSchema; + +export const noteChangeOutputSchema: OutputSchema = childChangeOutputSchema; + +export const listTagsOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'tags', + label: 'Tags', + labelKey: 'name', + listItems: [ + { key: 'id', label: 'Tag ID' }, + { key: 'name', label: 'Name' }, + { key: 'color_code', label: 'Color' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, + ], + }, + ], +}; + +export const createTagOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Tag ID' }, + { key: 'name', label: 'Name' }, + { key: 'color_code', label: 'Color' }, + { key: 'module', label: 'Module' }, + { key: 'created', label: 'Created (false = already existed)', format: 'boolean' }, + ], +}; + +export const relatedRecordsOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'record_id', label: 'Record ID' }, + { key: 'related_list', label: 'Related List' }, + ...pagingFields, + { key: 'next_page_token', label: 'Next Page Token' }, + { key: 'records', label: 'Related Records' }, + ], +}; + +export const linkRelatedOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'record_id', label: 'Record ID' }, + { key: 'related_list', label: 'Related List' }, + { key: 'related_record_id', label: 'Related Record ID' }, + { key: 'status', label: 'Status' }, + { key: 'code', label: 'Code' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const listAttachmentsOutputSchema: OutputSchema = { + fields: [...pagingFields, { key: 'attachments', label: 'Attachments', labelKey: 'file_name', listItems: attachmentFields }], +}; + +export const listDraftsOutputSchema: OutputSchema = { + fields: [...pagingFields, { key: 'drafts', label: 'Drafts', labelKey: 'subject', listItems: draftFields }], +}; + +export const draftWriteOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Draft ID' }, + { key: 'module', label: 'Module' }, + { key: 'record_id', label: 'Record ID' }, + { key: 'status', label: 'Status' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const downloadAttachmentOutputSchema: OutputSchema = { + fields: [ + { key: 'file', label: 'File' }, + { key: 'file_name', label: 'File Name' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'content_type', label: 'Content Type' }, + ], +}; diff --git a/packages/pieces/community/zoho-crm/src/lib/output-schemas.ts b/packages/pieces/community/zoho-crm/src/lib/output-schemas.ts new file mode 100644 index 000000000000..886c251e86ea --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/output-schemas.ts @@ -0,0 +1,102 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +const writeResultFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Record ID' }, + { key: 'module', label: 'Module' }, + { key: 'status', label: 'Status' }, + { key: 'code', label: 'Code' }, + { key: 'message', label: 'Message' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, + { key: 'created_by_id', label: 'Created By ID' }, + { key: 'created_by_name', label: 'Created By' }, + { key: 'modified_by_id', label: 'Modified By ID' }, + { key: 'modified_by_name', label: 'Modified By' }, +]; + +export const recordOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Record ID' }, + { key: 'Created_Time', label: 'Created Time', format: 'datetime' }, + { key: 'Modified_Time', label: 'Modified Time', format: 'datetime' }, + { key: 'record', label: 'All Fields', value: '', dynamicKey: true }, + ], +}; + +export const recordWriteOutputSchema: OutputSchema = { fields: writeResultFields }; + +export const upsertOutputSchema: OutputSchema = { + fields: [ + ...writeResultFields, + { key: 'action', label: 'Action (insert or update)' }, + { key: 'duplicate_field', label: 'Matched On Field' }, + ], +}; + +export const deleteOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Record ID' }, + { key: 'module', label: 'Module' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'status', label: 'Status' }, + { key: 'message', label: 'Message' }, + ], +}; + +export const convertLeadOutputSchema: OutputSchema = { + fields: [ + { key: 'lead_id', label: 'Lead ID' }, + { key: 'status', label: 'Status' }, + { key: 'message', label: 'Message' }, + { key: 'contact_id', label: 'Contact ID' }, + { key: 'contact_name', label: 'Contact Name' }, + { key: 'account_id', label: 'Account ID' }, + { key: 'account_name', label: 'Account Name' }, + { key: 'deal_id', label: 'Deal ID' }, + { key: 'deal_name', label: 'Deal Name' }, + ], +}; + +export const childWriteOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'parent_module', label: 'Parent Module' }, + { key: 'parent_id', label: 'Parent Record ID' }, + { key: 'status', label: 'Status' }, + { key: 'message', label: 'Message' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, + { key: 'created_by_name', label: 'Created By' }, + ], +}; + +export const childChangeOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'ID' }, + { key: 'status', label: 'Status' }, + { key: 'code', label: 'Code' }, + { key: 'message', label: 'Message' }, + { key: 'created_time', label: 'Created Time', format: 'datetime' }, + { key: 'modified_time', label: 'Modified Time', format: 'datetime' }, + { key: 'modified_by_name', label: 'Modified By' }, + ], +}; + +export const tagChangeOutputSchema: OutputSchema = { + fields: [ + { key: 'module', label: 'Module' }, + { key: 'success_count', label: 'Records Updated', format: 'number' }, + { key: 'failed_count', label: 'Records Failed', format: 'number' }, + { + key: 'results', + label: 'Results', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Record ID' }, + { key: 'status', label: 'Status' }, + { key: 'message', label: 'Message' }, + { key: 'tags', label: 'Tags Now On Record' }, + ], + }, + ], +}; diff --git a/packages/pieces/community/zoho-crm/src/lib/triggers/new-contact.ts b/packages/pieces/community/zoho-crm/src/lib/triggers/new-contact.ts index 1cd4dd203ffd..dce6bb80789c 100644 --- a/packages/pieces/community/zoho-crm/src/lib/triggers/new-contact.ts +++ b/packages/pieces/community/zoho-crm/src/lib/triggers/new-contact.ts @@ -1,19 +1,92 @@ -import { - AuthenticationType, - DedupeStrategy, - httpClient, - HttpMethod, - Polling, - pollingHelper, -} from '@activepieces/pieces-common'; -import { - AppConnectionValueForAuthProperty, - createTrigger, - OAuth2PropertyValue, - TriggerStrategy, -} from '@activepieces/pieces-framework'; -import dayjs from 'dayjs'; +import { Property, Store, createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; import { zohoCrmAuth } from '../auth'; +import { errorText } from '../common/client'; +import { listFields } from '../common/metadata'; +import { CURSOR_KEY, PollCursor, initCursor, zohoNow } from '../common/polling'; +import { pollModule, sampleModule } from '../common/record-trigger'; + +const MAX_EXTRA_FIELDS = 4; + +const CONTACT_FIELDS = [ + 'Owner', + 'Email', + '$currency_symbol', + '$field_states', + 'Other_Phone', + 'Mailing_State', + 'Other_State', + '$sharing_permission', + 'Other_Country', + 'Last_Activity_Time', + 'Department', + '$state', + 'Unsubscribed_Mode', + '$process_flow', + 'Assistant', + 'Mailing_Country', + 'id', + 'Reporting_To', + '$approval', + 'Enrich_Status__s', + 'Other_City', + 'Created_Time', + '$wizard_connection_path', + '$editable', + 'Home_Phone', + 'Created_By', + '$zia_owner_assignment', + 'Secondary_Email', + 'Description', + 'Vendor_Name', + 'Mailing_Zip', + '$review_process', + 'Twitter', + 'Other_Zip', + 'Mailing_Street', + '$canvas_id', + 'Salutation', + 'First_Name', + 'Full_Name', + 'Asst_Phone', + 'Record_Image', + 'Modified_By', + '$review', + 'Skype_ID', + 'Phone', + 'Account_Name', +]; + +export function contactFieldsParam(extra: unknown): string { + const picked = Array.isArray(extra) ? extra.map(String).filter((f) => f && !CONTACT_FIELDS.includes(f)) : []; + const unique = [...new Set(picked)].slice(0, MAX_EXTRA_FIELDS); + return [...CONTACT_FIELDS, ...unique].join(','); +} + +const newContactProps = { + additional_fields: Property.MultiSelectDropdown({ + auth: zohoCrmAuth, + displayName: 'Additional Fields', + description: `Optional. Up to ${MAX_EXTRA_FIELDS} extra Contacts fields (for example custom fields) to include. Zoho returns at most 50 fields per record and this trigger already requests 46 standard ones.`, + required: false, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { disabled: true, options: [], placeholder: 'Connect your Zoho CRM account first' }; + } + try { + const fields = await listFields({ auth, module: 'Contacts' }); + return { + disabled: false, + options: fields + .filter((f) => f.visible !== false && !CONTACT_FIELDS.includes(f.api_name)) + .map((f) => ({ label: f.display_label ?? f.field_label ?? f.api_name, value: f.api_name })), + }; + } catch (error) { + return { disabled: true, options: [], placeholder: `Could not load fields: ${errorText(error)}` }; + } + }, + }), +}; export const newContact = createTrigger({ auth: zohoCrmAuth, @@ -94,108 +167,48 @@ export const newContact = createTrigger({ Secondary_Email: null, }, type: TriggerStrategy.POLLING, - props: {}, + props: newContactProps, + async onEnable(context): Promise { + if (context.isRepublish) { + await adoptLegacyCursor(context.store); + } + await initCursor({ store: context.store, isRepublish: context.isRepublish, now: () => zohoNow({ auth: context.auth, module: 'Contacts' }) }); + }, + async onDisable(): Promise { + return; + }, async run(context) { - return await pollingHelper.poll(polling, { + await adoptLegacyCursor(context.store); + return pollModule({ auth: context.auth, store: context.store, - propsValue: context.propsValue, - files: context.files, + module: 'Contacts', + fields: contactFieldsParam(context.propsValue.additional_fields).split(','), + sortBy: 'Created_Time', + apiVersion: LEGACY_API_VERSION, }); }, - async test({ auth, propsValue, store, files }): Promise { - return await pollingHelper.test(polling, { - auth, - store: store, - propsValue: propsValue, - files: files, - }); - }, - async onEnable({ auth, propsValue, store }): Promise { - await pollingHelper.onEnable(polling, { - auth, - store: store, - propsValue: propsValue, - }); - }, - async onDisable({ auth, propsValue, store }): Promise { - await pollingHelper.onDisable(polling, { - auth, - store: store, - propsValue: propsValue, + async test(context): Promise { + return sampleModule({ + auth: context.auth, + module: 'Contacts', + fields: contactFieldsParam(context.propsValue.additional_fields).split(','), + sortBy: 'Created_Time', + apiVersion: LEGACY_API_VERSION, }); }, }); -const polling: Polling, unknown> = { - strategy: DedupeStrategy.TIMEBASED, - items: async ({ auth }) => { - const response = await httpClient.sendRequest<{ - data: { Created_Time: string }[]; - }>({ - url: `${auth.data.api_domain}/crm/v4/Contacts`, - method: HttpMethod.GET, - queryParams: { - perPage: '200', - sort_order: 'desc', - sort_by: 'Created_Time', - fields: [ - 'Owner', - 'Email', - '$currency_symbol', - '$field_states', - 'Other_Phone', - 'Mailing_State', - 'Other_State', - '$sharing_permission', - 'Other_Country', - 'Last_Activity_Time', - 'Department', - '$state', - 'Unsubscribed_Mode', - '$process_flow', - 'Assistant', - 'Mailing_Country', - 'id', - 'Reporting_To', - '$approval', - 'Enrich_Status__s', - 'Other_City', - 'Created_Time', - '$wizard_connection_path', - '$editable', - 'Home_Phone', - 'Created_By', - '$zia_owner_assignment', - 'Secondary_Email', - 'Description', - 'Vendor_Name', - 'Mailing_Zip', - '$review_process', - 'Twitter', - 'Other_Zip', - 'Mailing_Street', - '$canvas_id', - 'Salutation', - 'First_Name', - 'Full_Name', - 'Asst_Phone', - 'Record_Image', - 'Modified_By', - '$review', - 'Skype_ID', - 'Phone', - 'Account_Name', - ].join(','), - }, - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token: auth.access_token, - }, - }); - return response.body.data.map((record) => ({ - epochMilliSeconds: dayjs(record.Created_Time).valueOf(), - data: record, - })); - }, -}; +async function adoptLegacyCursor(store: Store): Promise { + if (await store.get(CURSOR_KEY)) { + return; + } + const lastPoll = await store.get(LEGACY_LAST_POLL_KEY); + if (typeof lastPoll === 'number' && Number.isFinite(lastPoll)) { + await store.put(CURSOR_KEY, { time: lastPoll + 1, ids: [] }); + await store.delete(LEGACY_LAST_POLL_KEY); + } +} + +const LEGACY_LAST_POLL_KEY = 'lastPoll'; +const LEGACY_API_VERSION = 'v4'; diff --git a/packages/pieces/community/zoho-crm/src/lib/triggers/new-record.ts b/packages/pieces/community/zoho-crm/src/lib/triggers/new-record.ts new file mode 100644 index 000000000000..8f9a90bd3c00 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/triggers/new-record.ts @@ -0,0 +1,57 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName } from '../common/client'; +import { recordOutputSchema } from '../output-schemas'; +import { pollModule, recordTriggerProps, sampleModule, startTrigger, triggerFields } from '../common/record-trigger'; + +export const newRecordTrigger = createTrigger({ + auth: zohoCrmAuth, + name: 'new_record', + classification: 'READ', + displayName: 'New Record', + description: 'Triggers when a record is created in any module (custom fields included).', + aiMetadata: { + description: + 'Fires once per record newly created in the chosen Zoho CRM module (standard or custom), emitting the record with its field API names as keys, custom fields included (up to 50 fields). Polls by Created_Time.', + }, + props: recordTriggerProps, + sampleData: { + id: '5725767000000524157', + Last_Name: 'Doe', + First_Name: 'Jane', + Email: 'jane.doe@example.com', + Company: 'Acme Inc', + Lead_Source: 'Web Download', + Custom_Field__c: 'value', + Owner: { name: 'Jane Admin', id: '5725767000000411001', email: 'admin@example.com' }, + Created_Time: '2026-09-29T10:15:00+02:00', + Modified_Time: '2026-09-29T10:15:00+02:00', + }, + type: TriggerStrategy.POLLING, + outputSchema: recordOutputSchema, + async onEnable(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + await startTrigger({ auth: context.auth, module, store: context.store, isRepublish: context.isRepublish, chosen: context.propsValue.fields }); + }, + async onDisable() { + }, + async run(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + return pollModule({ + auth: context.auth, + store: context.store, + module, + fields: await triggerFields({ auth: context.auth, store: context.store, module, chosen: context.propsValue.fields }), + sortBy: 'Created_Time', + }); + }, + async test(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + return sampleModule({ + auth: context.auth, + module, + fields: await triggerFields({ auth: context.auth, module, chosen: context.propsValue.fields }), + sortBy: 'Created_Time', + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/src/lib/triggers/updated-record.ts b/packages/pieces/community/zoho-crm/src/lib/triggers/updated-record.ts new file mode 100644 index 000000000000..4d1850e611f4 --- /dev/null +++ b/packages/pieces/community/zoho-crm/src/lib/triggers/updated-record.ts @@ -0,0 +1,69 @@ +import { createTrigger, Property, TriggerStrategy } from '@activepieces/pieces-framework'; +import { zohoCrmAuth } from '../auth'; +import { requireApiName } from '../common/client'; +import { recordOutputSchema } from '../output-schemas'; +import { ZohoRecord } from '../common/polling'; +import { pollModule, recordTriggerProps, sampleModule, startTrigger, triggerFields } from '../common/record-trigger'; + +export function isUpdateOnly(record: ZohoRecord): boolean { + return record['Modified_Time'] !== record['Created_Time']; +} + +export const updatedRecordTrigger = createTrigger({ + auth: zohoCrmAuth, + name: 'updated_record', + classification: 'READ', + displayName: 'Updated Record', + description: 'Triggers when a record is modified in any module.', + aiMetadata: { + description: + 'Fires once per record modified in the chosen Zoho CRM module since the last poll, emitting the record at its latest state (up to 50 fields, custom fields included). Polls by Modified_Time; by default records that were only created (never edited) are skipped, and several edits between two polls produce one event.', + }, + props: { + ...recordTriggerProps, + include_new: Property.Checkbox({ + displayName: 'Include Newly Created Records', + description: 'Also fire for records created but not edited since the last check.', + required: false, + defaultValue: false, + }), + }, + sampleData: { + id: '5725767000000524157', + Last_Name: 'Doe', + Email: 'jane.doe@example.com', + Lead_Status: 'Contacted', + Created_Time: '2026-09-28T10:15:00+02:00', + Modified_Time: '2026-09-29T11:02:41+02:00', + Modified_By: { name: 'Jane Admin', id: '5725767000000411001', email: 'admin@example.com' }, + }, + type: TriggerStrategy.POLLING, + outputSchema: recordOutputSchema, + async onEnable(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + await startTrigger({ auth: context.auth, module, store: context.store, isRepublish: context.isRepublish, chosen: context.propsValue.fields }); + }, + async onDisable() { + }, + async run(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + return pollModule({ + auth: context.auth, + store: context.store, + module, + fields: await triggerFields({ auth: context.auth, store: context.store, module, chosen: context.propsValue.fields }), + sortBy: 'Modified_Time', + keep: context.propsValue.include_new === true ? undefined : isUpdateOnly, + }); + }, + async test(context) { + const module = requireApiName({ value: context.propsValue.module, name: 'Module' }); + return sampleModule({ + auth: context.auth, + module, + fields: await triggerFields({ auth: context.auth, module, chosen: context.propsValue.fields }), + sortBy: 'Modified_Time', + keep: context.propsValue.include_new === true ? undefined : isUpdateOnly, + }); + }, +}); diff --git a/packages/pieces/community/zoho-crm/test/ai-atomics.test.ts b/packages/pieces/community/zoho-crm/test/ai-atomics.test.ts new file mode 100644 index 000000000000..37577f25f9ab --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/ai-atomics.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { zohoAiActions } from '../src/lib/actions/ai'; +import { listRecordsAtomic, updateRecordAtomic, upsertRecordAtomic } from '../src/lib/actions/ai/records'; +import { mergeDraft, updateEmailDraftAtomic } from '../src/lib/actions/ai/drafts'; +import { addTagsAtomic, createTagAtomic, updateNoteAtomic } from '../src/lib/actions/ai/notes-tags'; +import { downloadAttachmentAtomic, listRelatedRecordsAtomic, linkRelatedRecordAtomic } from '../src/lib/actions/ai/related-attachments'; +import { fileNameFromDisposition } from '../src/lib/common/download'; +import { getRecordAtomic } from '../src/lib/actions/ai/records'; +import { convertLeadAtomic, createEventAtomic, createLeadAtomic } from '../src/lib/actions/ai/typed'; +import { zohoCrm } from '../src/index'; +import { AUTH_EU, call, httpError, ok, runAction, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const LIST = { fields: undefined, per_page: undefined, page: undefined, page_token: undefined, sort_by: undefined, sort_order: undefined, custom_view_id: undefined }; +const WRITE = { skip_automation: false }; +const LEAD = { + first_name: undefined, company: undefined, email: undefined, phone: undefined, mobile: undefined, title: undefined, website: undefined, + lead_source: undefined, lead_status: undefined, description: undefined, owner_id: undefined, additional_fields: undefined, +}; +const EVENT = { all_day: false, venue: undefined, description: undefined, related_module: undefined, related_record_id: undefined, owner_id: undefined, additional_fields: undefined }; +const CONVERT = { + account_id: undefined, contact_id: undefined, assign_to: undefined, overwrite: false, notify_lead_owner: false, notify_new_entity_owner: false, + deal_name: undefined, deal_stage: undefined, deal_closing_date: undefined, deal_amount: undefined, deal_pipeline: undefined, +}; +const RELATED = { + module_api_name: 'Accounts', record_id: '1', related_list_api_name: 'Contacts', fields: undefined, page: undefined, per_page: undefined, page_token: undefined, +}; +const DRAFT = { from: undefined, to: undefined, cc: undefined, bcc: undefined, subject: undefined, content: undefined, rich_text: undefined, reply_to: undefined }; + +beforeEach(() => sendRequest.mockReset()); + +describe('agent surface metadata', () => { + it('ships 34 ai atomics with metadata, and demotes the PR A twins', () => { + expect(zohoAiActions).toHaveLength(34); + for (const a of zohoAiActions) { + expect(a.audience).toBe('ai'); + expect(a.name.startsWith('zoho_crm_')).toBe(true); + expect(a.aiMetadata?.description?.length).toBeGreaterThan(40); + expect(typeof a.aiMetadata?.idempotent).toBe('boolean'); + expect(a.classification).toBeDefined(); + } + const trues = zohoAiActions.filter((a) => a.aiMetadata?.idempotent === true).length; + expect(trues).toBe(21); + const actions = zohoCrm.actions(); + expect(Object.keys(actions)).toHaveLength(45); + for (const name of ['create_record', 'update_record', 'get_record', 'upsert_record', 'delete_record', 'convert_lead', 'add_note', 'add_tags_to_record', 'upload_attachment']) { + expect(actions[name].audience).toBe('human'); + } + expect(actions['read-file'].audience).toBe('both'); + }); +}); + +describe('list_records', () => { + it('fills fields from metadata when omitted and sends documented params', async () => { + ok({ body: { fields: [{ api_name: 'id' }, { api_name: 'Last_Name' }, { api_name: 'Custom__c' }, { api_name: 'Hidden', visible: false }] } }); + ok({ body: { data: [{ id: '1', Custom__c: 'v' }], info: { more_records: true, next_page_token: 'tkn', page: 1 } } }); + const out = await runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads', per_page: 2, sort_by: 'Created_Time' } }); + expect(call(1).url).toBe('https://www.zohoapis.eu/crm/v8/Leads'); + expect(call(1).queryParams).toEqual({ fields: 'id,Last_Name,Custom__c', per_page: '2', sort_by: 'Created_Time' }); + expect(out).toMatchObject({ count: 1, more_records: true, next_page_token: 'tkn' }); + }); + + it('handles 204 as an empty page', async () => { + ok({ body: undefined, status: 204 }); + const out = await runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads', fields: ['id'] } }); + expect(out).toMatchObject({ records: [], count: 0, more_records: false }); + }); + + it('rejects page with page_token, pages past 2,000, and >50 fields before any request', async () => { + await expect(runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads', page: 2, page_token: 'x', fields: ['id'] } })).rejects.toThrow(/either page or page_token/); + await expect(runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads', page: 11, per_page: 200, fields: ['id'] } })).rejects.toThrow(/2,000/); + await expect(runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads', fields: Array.from({ length: 51 }, (_, i) => `F${i}`) } })).rejects.toThrow(/50 fields/); + await expect(runAction({ action: listRecordsAtomic, auth: AUTH_EU, propsValue: { ...LIST, module_api_name: 'Leads/../x', fields: ['id'] } })).rejects.toThrow(/API name/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('record writes', () => { + it('update sends exactly the given keys and refuses id in data', async () => { + ok({ body: { data: [{ status: 'success', code: 'SUCCESS', details: { id: '5725767000000524157' } }] } }); + await runAction({ action: updateRecordAtomic, auth: AUTH_EU, propsValue: { ...WRITE, module_api_name: 'Deals', record_id: '5725767000000524157', data: { Stage: 'Closed Won' }, append_multiselect_fields: undefined } }); + expect(call(0)).toMatchObject({ method: 'PUT', url: 'https://www.zohoapis.eu/crm/v8/Deals/5725767000000524157', body: { data: [{ Stage: 'Closed Won' }] } }); + await expect(runAction({ action: updateRecordAtomic, auth: AUTH_EU, propsValue: { ...WRITE, module_api_name: 'Deals', record_id: '1', data: { id: '2' }, append_multiselect_fields: undefined } })).rejects.toThrow(/record_id/); + }); + + it('upsert sends duplicate_check_fields and surfaces action', async () => { + ok({ body: { data: [{ status: 'success', action: 'update', duplicate_field: 'Email', details: { id: '7' } }] } }); + const out = await runAction({ action: upsertRecordAtomic, auth: AUTH_EU, propsValue: { ...WRITE, module_api_name: 'Leads', data: { Last_Name: 'D', Email: 'd@x.com' }, duplicate_check_fields: ['Email'] } }); + expect(call(0).url).toBe('https://www.zohoapis.eu/crm/v8/Leads/upsert'); + expect(call(0).body).toEqual({ data: [{ Last_Name: 'D', Email: 'd@x.com' }], duplicate_check_fields: ['Email'] }); + expect(out).toMatchObject({ id: '7', action: 'update', duplicate_field: 'Email' }); + }); + + it('create_lead: typed props win over additional_fields', async () => { + ok({ body: { data: [{ status: 'success', details: { id: '1' } }] }, status: 201 }); + await runAction({ action: createLeadAtomic, auth: AUTH_EU, propsValue: { ...LEAD, last_name: 'Doe', email: 'a@x.com', additional_fields: { Email: 'b@x.com', Custom__c: 1 }, owner_id: '42' } }); + expect(call(0).body.data[0]).toEqual({ Email: 'a@x.com', Custom__c: 1, Last_Name: 'Doe', Owner: { id: '42' } }); + }); + + it('create_event validates the related pair and the time order', async () => { + await expect(runAction({ action: createEventAtomic, auth: AUTH_EU, propsValue: { ...EVENT, event_title: 'x', start_datetime: '2026-10-01T09:00:00+02:00', end_datetime: '2026-10-01T10:00:00+02:00', related_module: 'Deals' } })).rejects.toThrow(/together/); + await expect(runAction({ action: createEventAtomic, auth: AUTH_EU, propsValue: { ...EVENT, event_title: 'x', start_datetime: '2026-10-01T09:00:00+02:00', end_datetime: '2026-10-01T08:00:00+02:00' } })).rejects.toThrow(/not be before/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('convert_lead requires stage + closing date with a deal', async () => { + await expect(runAction({ action: convertLeadAtomic, auth: AUTH_EU, propsValue: { ...CONVERT, lead_id: '1', deal_name: 'D' } })).rejects.toThrow(/deal_stage/); + ok({ body: { data: [{ status: 'success', details: { Contacts: { id: '2', name: 'C' }, Accounts: { id: '3', name: 'A' }, Deals: { id: '4', name: 'D' } } }] } }); + const out = await runAction({ action: convertLeadAtomic, auth: AUTH_EU, propsValue: { ...CONVERT, lead_id: '1', deal_name: 'D', deal_stage: 'Qualification', deal_closing_date: '2026-12-31' } }); + expect(call(0).body).toEqual({ data: [{ Deals: { Deal_Name: 'D', Stage: 'Qualification', Closing_Date: '2026-12-31' } }] }); + expect(out).toMatchObject({ contact_id: '2', account_id: '3', deal_id: '4' }); + }); +}); + +describe('notes, tags, related', () => { + it('update_note refuses an empty update and sends only the changed key', async () => { + await expect(runAction({ action: updateNoteAtomic, auth: AUTH_EU, propsValue: { note_id: '1', title: undefined, content: undefined } })).rejects.toThrow(/title or content/); + ok({ body: { data: [{ status: 'success', details: { id: '1' } }] } }); + await runAction({ action: updateNoteAtomic, auth: AUTH_EU, propsValue: { note_id: '1', title: undefined, content: 'new' } }); + expect(call(0).body).toEqual({ data: [{ Note_Content: 'new' }] }); + }); + + it('create_tag returns the existing tag without creating', async () => { + ok({ body: { tags: [{ id: '10', name: 'VIP', color_code: '#F17574' }] } }); + const out = await runAction({ action: createTagAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Leads', name: 'vip', color_code: undefined } }); + expect(out).toEqual({ id: '10', name: 'VIP', color_code: '#F17574', module: 'Leads', created: false }); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('create_tag creates when missing and validates color', async () => { + await expect(runAction({ action: createTagAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Leads', name: 'x', color_code: '#000000' } })).rejects.toThrow(/color_code/); + ok({ body: { tags: [] } }); + ok({ body: { tags: [{ code: 'SUCCESS', status: 'success', details: { id: '11' } }] } }); + const out = await runAction({ action: createTagAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Leads', name: 'New', color_code: undefined } }); + expect(call(1).body).toEqual({ tags: [{ name: 'New' }] }); + expect(out).toMatchObject({ id: '11', created: true }); + }); + + it('add_tags keeps existing tags (over_write false) and caps ids', async () => { + ok({ body: { data: [{ status: 'success', details: { id: '1', tags: [{ name: 'A' }, { name: 'B' }] } }] } }); + const out = await runAction({ action: addTagsAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Leads', record_ids: ['1'], tags: ['B'] } }); + expect(call(0).body).toEqual({ tags: [{ name: 'B' }], ids: ['1'], over_write: false }); + expect(out).toMatchObject({ success_count: 1, failed_count: 0 }); + await expect(runAction({ action: addTagsAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Leads', record_ids: Array.from({ length: 501 }, (_, i) => String(i + 1)), tags: ['B'] } })).rejects.toThrow(/500/); + }); + + it('link_related_record uses the documented batch body', async () => { + ok({ body: { data: [{ status: 'success', code: 'SUCCESS', details: { id: '9' } }] } }); + await runAction({ action: linkRelatedRecordAtomic, auth: AUTH_EU, propsValue: { module_api_name: 'Campaigns', record_id: '1', related_list_api_name: 'Contacts', related_record_id: '9', relation_fields: { Member_Status: 'Invited' } } }); + expect(call(0)).toMatchObject({ method: 'PUT', url: 'https://www.zohoapis.eu/crm/v8/Campaigns/1/Contacts', body: { data: [{ Member_Status: 'Invited', id: '9' }] } }); + }); + + it('list_related_records fails when Zoho cannot list related lists, instead of returning only ids', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 500, body: { code: 'INTERNAL_ERROR', message: 'boom' } })); + await expect(runAction({ action: listRelatedRecordsAtomic, auth: AUTH_EU, propsValue: RELATED })).rejects.toThrow(/INTERNAL_ERROR/); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('list_related_records falls back to id only when the user may not read field metadata', async () => { + ok({ body: { related_lists: [{ api_name: 'Contacts', module: { api_name: 'Contacts' } }] } }); + sendRequest.mockRejectedValueOnce(httpError({ status: 403, body: { code: 'NO_PERMISSION', message: 'permission denied' } })); + ok({ body: { data: [{ id: '5' }], info: { more_records: false } } }); + const out = await runAction({ action: listRelatedRecordsAtomic, auth: AUTH_EU, propsValue: RELATED }); + expect(call(2).queryParams.fields).toBe('id'); + expect(out).toMatchObject({ count: 1 }); + }); + + it('parses attachment file names', () => { + expect(fileNameFromDisposition({ header: 'attachment; filename="report.pdf"', fallback: 'x' })).toBe('report.pdf'); + expect(fileNameFromDisposition({ header: "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf", fallback: 'x' })).toBe('résumé.pdf'); + expect(fileNameFromDisposition({ header: undefined, fallback: 'fallback' })).toBe('fallback'); + }); +}); + +describe('output schemas', () => { + it('get_record and download_attachment describe what run() returns', () => { + expect(getRecordAtomic.outputSchema?.fields.map((f) => f.key)).toEqual(['id', 'Created_Time', 'Modified_Time', 'record']); + expect(downloadAttachmentAtomic.outputSchema?.fields.map((f) => f.key)).toEqual(['file', 'file_name', 'size', 'content_type']); + }); +}); + +describe('email draft partial update', () => { + const current = { + id: 'abc', + from: 'me@x.com', + to: [{ email: 'a@x.com', user_name: 'A' }], + cc: [{ email: 'c@x.com' }], + subject: 'Old subject', + content: '

Old

', + rich_text: true, + schedule_details: { time: '2026-11-30T18:40:00+05:30' }, + attachments: [{ id: 'att', file_name: 'f.txt' }], + }; + + it('keeps every field not passed', () => { + const merged = mergeDraft({ current, changes: { subject: 'New' }, draft: 'abc' }); + expect(merged).toEqual({ + id: 'abc', + from: 'me@x.com', + rich_text: true, + to: [{ email: 'a@x.com', user_name: 'A' }], + cc: [{ email: 'c@x.com' }], + subject: 'New', + content: '

Old

', + schedule_details: { time: '2026-11-30T18:40:00+05:30' }, + attachments: [{ id: 'att', file_name: 'f.txt' }], + }); + }); + + it('reads first, then PUTs /{module}/{id}/__email_drafts with the merged draft', async () => { + ok({ body: { __email_drafts: [current] } }); + ok({ body: { __email_drafts: [{ code: 'SUCCESS', status: 'success', details: { id: 'abc' } }] } }); + const out = await runAction({ action: updateEmailDraftAtomic, auth: AUTH_EU, propsValue: { ...DRAFT, module_api_name: 'Leads', record_id: '1', draft_id: 'abc', to: ['z@x.com'] } }); + expect(call(0).url).toBe('https://www.zohoapis.eu/crm/v8/Leads/1/__email_drafts/abc'); + expect(call(1).method).toBe('PUT'); + expect(call(1).url).toBe('https://www.zohoapis.eu/crm/v8/Leads/1/__email_drafts'); + expect(call(1).body.__email_drafts[0]).toMatchObject({ id: 'abc', from: 'me@x.com', to: [{ email: 'z@x.com' }], subject: 'Old subject', cc: [{ email: 'c@x.com' }] }); + expect(out).toMatchObject({ id: 'abc', status: 'success' }); + }); +}); diff --git a/packages/pieces/community/zoho-crm/test/back-compat.test.ts b/packages/pieces/community/zoho-crm/test/back-compat.test.ts new file mode 100644 index 000000000000..5ec0dd4e188d --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/back-compat.test.ts @@ -0,0 +1,61 @@ +import { Readable } from 'node:stream'; +import { PropertyType } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFile } from '../src/lib/actions/read-file'; +import { newContact } from '../src/lib/triggers/new-contact'; +import { zohoCrm } from '../src/index'; +import { asPolling, call, captureWrites, memoryStore, ok, runAction, runTrigger, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const MAIN_CONTACT_FIELDS = + 'Owner,Email,$currency_symbol,$field_states,Other_Phone,Mailing_State,Other_State,$sharing_permission,Other_Country,Last_Activity_Time,Department,$state,Unsubscribed_Mode,$process_flow,Assistant,Mailing_Country,id,Reporting_To,$approval,Enrich_Status__s,Other_City,Created_Time,$wizard_connection_path,$editable,Home_Phone,Created_By,$zia_owner_assignment,Secondary_Email,Description,Vendor_Name,Mailing_Zip,$review_process,Twitter,Other_Zip,Mailing_Street,$canvas_id,Salutation,First_Name,Full_Name,Asst_Phone,Record_Image,Modified_By,$review,Skype_ID,Phone,Account_Name'; +const T0 = Date.parse('2026-09-29T10:00:00+00:00'); + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('steps that exist on main keep their inputs and outputs', () => { + it('New Contact asks the same API version and fields as 0.3.1 and passes the record through unchanged', async () => { + const contact = { + id: '1', + Created_Time: new Date(T0 + 1000).toISOString().replace('.000Z', '+00:00'), + $state: 'save', + Owner: { name: 'Ann', id: '2', email: 'ann@example.com' }, + Account_Name: { name: 'Acme', id: '3' }, + $approval: { delegate: false, approve: false, reject: false, resubmit: false }, + }; + ok({ body: { data: [contact], info: { more_records: false } } }); + const { store } = memoryStore(); + await store.put('zoho_poll_cursor', { time: T0, ids: [] }); + const out = await runTrigger({ trigger: asPolling(newContact), propsValue: { additional_fields: undefined }, store }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(call(0).url).toBe('https://www.zohoapis.com/crm/v4/Contacts'); + expect(call(0).queryParams).toMatchObject({ fields: MAIN_CONTACT_FIELDS, per_page: '200', sort_by: 'Created_Time', sort_order: 'desc' }); + expect(out).toEqual([contact]); + }); + + it('New Contact still accepts the empty input that 0.3.1 flows saved', () => { + expect(Object.keys(newContact.props)).toEqual(['additional_fields']); + expect(newContact.props.additional_fields.required).toBe(false); + }); + + it('Read File keeps its single url input and still returns the stored file reference', async () => { + expect(Object.keys(readFile.props)).toEqual(['url']); + expect(readFile.props.url.type).toBe(PropertyType.SHORT_TEXT); + ok({ body: Readable.from([Buffer.from('zip')]) }); + const written = captureWrites(); + const out = await runAction({ action: readFile, propsValue: { url: 'https://www.zohoapis.com/crm/bulk/v8/read/1/result' }, files: written.files }); + expect(out).toBe('file://stored'); + }); + + it('Custom API Call keeps its inputs', () => { + const action = zohoCrm.actions()['custom_api_call']; + expect(Object.keys(action.props)).toEqual(['url', 'method', 'headers', 'queryParams', 'body_type', 'body', 'response_is_binary', 'failsafe', 'timeout', 'followRedirects']); + }); +}); diff --git a/packages/pieces/community/zoho-crm/test/greptile-1.test.ts b/packages/pieces/community/zoho-crm/test/greptile-1.test.ts new file mode 100644 index 000000000000..f61ac1a9585e --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/greptile-1.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRecordAction } from '../src/lib/actions/create-record'; +import { readFile } from '../src/lib/actions/read-file'; +import { createRecordAtomic } from '../src/lib/actions/ai/records'; +import { updateEmailDraftAtomic } from '../src/lib/actions/ai/drafts'; +import { call, ok, runAction, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const CREATE_DEFAULTS = { additional_fields: undefined, triggers: undefined, skip_automation: false }; +const BIG_FIELDS = { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Big__c', data_type: 'bigint' }, { api_name: 'Amount__c', data_type: 'currency' }] }; +const DRAFT = { from: undefined, to: undefined, cc: undefined, bcc: undefined, subject: undefined, content: undefined, rich_text: undefined, reply_to: undefined }; +const CURRENT_DRAFT = { id: 'abc', from: 'me@x.com', to: [{ email: 'a@x.com' }], cc: [{ email: 'c@x.com' }], bcc: [{ email: 'b@x.com' }], subject: 'S', content: 'C', rich_text: true }; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('long integers and exact numbers', () => { + it('refuses a long integer that arrives as an already-rounded number', async () => { + ok({ body: BIG_FIELDS }); + await expect( + runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Big__c: 123456789012345678 } } }), + ).rejects.toThrow(/pass long integers above 2\^53 as text/); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('refuses decimal text that cannot be sent exactly, and accepts exact values', async () => { + ok({ body: BIG_FIELDS }); + await expect( + runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Amount__c: '12345678901234567.89' } } }), + ).rejects.toThrow(/sent exactly/); + ok({ body: BIG_FIELDS }); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Amount__c: '1234.50', Big__c: 42 } } }); + expect(call(2).body.data[0]).toEqual({ Last_Name: 'Doe', Amount__c: 1234.5, Big__c: '42' }); + }); + + it('refuses an unsafe number anywhere in an agent record before any request', async () => { + await expect( + runAction({ action: createRecordAtomic, propsValue: { module_api_name: 'Leads', data: { Last_Name: 'Doe', Big__c: 123456789012345678 }, skip_automation: false } }), + ).rejects.toThrow(/Big__c arrived as a number above 2\^53/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('Update Email Draft recipient lists', () => { + it('refuses an empty recipient list before any request, because Zoho silently keeps the old recipients', async () => { + for (const list of ['to', 'cc', 'bcc']) { + await expect( + runAction({ action: updateEmailDraftAtomic, propsValue: { ...DRAFT, module_api_name: 'Leads', record_id: '1', draft_id: 'abc', [list]: [] } }), + ).rejects.toThrow(new RegExp(`${list} is empty`)); + } + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('keeps omitted recipient lists and replaces the ones passed', async () => { + ok({ body: { __email_drafts: [CURRENT_DRAFT] } }); + ok({ body: { __email_drafts: [{ status: 'success', details: { id: 'abc' } }] } }); + await runAction({ action: updateEmailDraftAtomic, propsValue: { ...DRAFT, module_api_name: 'Leads', record_id: '1', draft_id: 'abc', cc: ['n@x.com'] } }); + expect(call(1).body.__email_drafts[0]).toMatchObject({ to: [{ email: 'a@x.com' }], cc: [{ email: 'n@x.com' }], bcc: [{ email: 'b@x.com' }] }); + }); +}); + +describe('download redirects', () => { + it.each([ + 'https://169.254.169.254/latest/meta-data/', + 'https://127.0.0.1/x', + 'https://localhost/x', + 'https://10.0.0.5/x', + 'https://[::1]/x', + 'https://cdn.example-storage.net/object?sig=abc', + 'https://www.zohoapis.eu/crm/v8/org', + 'https://download.zoho.com:8443/v2/crm/1/f.zip', + ])('refuses a redirect to %s before requesting it', async (location) => { + ok({ body: undefined, status: 302, headers: { location } }); + await expect(runAction({ action: readFile, propsValue: { url: 'https://www.zohoapis.com/crm/bulk/v8/read/1/result' } })).rejects.toThrow(/stopped/); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/pieces/community/zoho-crm/test/helpers.ts b/packages/pieces/community/zoho-crm/test/helpers.ts new file mode 100644 index 000000000000..0c2a9982d395 --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/helpers.ts @@ -0,0 +1,178 @@ +import { + ActionContext, + AppConnectionType, + InputPropertyMap, + PropertyContext, + StaticPropsValue, + Store, + TestOrRunHookContext, + TriggerHookContext, + TriggerStrategy, + createMockActionContext, + createMockPollingTriggerContext, +} from '@activepieces/pieces-framework'; +import { Readable } from 'node:stream'; +import { vi } from 'vitest'; +import { zohoCrmAuth } from '../src/lib/auth'; +import type { ZohoAuth } from '../src/lib/common/client'; + +export const sendRequest = vi.fn(); + +export function mockHttpClient() { + return { + httpClient: { sendRequest: (...args: unknown[]) => sendRequest(...args) }, + }; +} + +export function ok({ body, status = 200, headers = {} }: { body: unknown; status?: number; headers?: Record }) { + sendRequest.mockResolvedValueOnce({ status, headers, body }); +} + +export function captureWrites(): { files: ActionContext['files']; calls: { fileName: string; streamed: boolean; content: string }[] } { + const calls: { fileName: string; streamed: boolean; content: string }[] = []; + const write = async ({ fileName, data }: { fileName: string; data: Buffer | Readable }): Promise => { + const streamed = !Buffer.isBuffer(data); + const chunks: Buffer[] = []; + if (Buffer.isBuffer(data)) { + chunks.push(data); + } else { + for await (const chunk of data) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + } + calls.push({ fileName, streamed, content: Buffer.concat(chunks).toString() }); + return 'file://stored'; + }; + return { files: { write, upload: async () => ({ id: 'f', url: 'file://stored' }) }, calls }; +} + +export function httpError({ status, body }: { status: number; body: unknown }): Error { + return Object.assign(new Error(`HTTP ${status}`), { response: { status, body } }); +} + +export function call(index: number) { + return sendRequest.mock.calls[index][0]; +} + +export function memoryStore(): { store: Store; read: (key: string) => unknown } { + const map = new Map(); + const store: Store = { + put: async (key, value) => { + map.set(key, JSON.stringify(value)); + return value; + }, + get: async (key) => { + const raw = map.get(key); + return raw === undefined ? null : JSON.parse(raw); + }, + delete: async (key) => { + map.delete(key); + }, + }; + return { + store, + read: (key) => { + const raw = map.get(key); + return raw === undefined ? undefined : JSON.parse(raw); + }, + }; +} + +export function runAction({ + action, + propsValue, + files, + auth = AUTH, +}: { + action: { props: Props; run: (context: ActionContext) => Promise }; + propsValue: StaticPropsValue; + files?: ActionContext['files']; + auth?: ZohoAuth; +}): Promise { + const base = createMockActionContext({ propsValue }); + return action.run({ ...base, auth, files: files ?? base.files }); +} + +export function asPolling(trigger: T): Extract { + if (!isPolling(trigger)) { + throw new Error('not a polling trigger'); + } + return trigger; +} + +export function runTrigger({ + trigger, + propsValue, + store, +}: { + trigger: PollingTriggerLike; + propsValue: StaticPropsValue; + store: Store; +}): Promise { + return trigger.run(triggerContext({ propsValue, store })); +} + +export function enableTrigger({ + trigger, + propsValue, + store, + isRepublish, +}: { + trigger: PollingTriggerLike; + propsValue: StaticPropsValue; + store: Store; + isRepublish?: boolean; +}): Promise { + return trigger.onEnable(triggerContext({ propsValue, store, isRepublish })); +} + +export function propertyContext(): PropertyContext { + const base = createMockActionContext({ propsValue: {} }); + return { server: base.server, project: base.project, flows: base.flows, connections: base.connections }; +} + +function isPolling(trigger: T): trigger is Extract { + return trigger.type === TriggerStrategy.POLLING; +} + +function triggerContext({ + propsValue, + store, + isRepublish, +}: { + propsValue: StaticPropsValue; + store: Store; + isRepublish?: boolean; +}): TestOrRunHookContext { + const base = createMockActionContext({ propsValue }); + return { + ...createMockPollingTriggerContext({ propsValue }), + auth: AUTH, + store, + files: base.files, + isRepublish, + }; +} + +type PollingTriggerLike = { + props: Props; + run: (context: TestOrRunHookContext) => Promise; + onEnable: (context: TriggerHookContext) => Promise; +}; + +export const AUTH = { + type: AppConnectionType.OAUTH2, + access_token: 'tok-123', + client_id: 'client', + client_secret: 'secret', + redirect_url: 'https://localhost/redirect', + token_type: 'Bearer', + claimed_at: 0, + refresh_token: 'refresh', + scope: 'ZohoCRM.modules.ALL', + token_url: 'https://accounts.zoho.com/oauth/v2/token', + data: { api_domain: 'https://www.zohoapis.com' }, + props: { location: 'zoho.com' }, +} satisfies ZohoAuth; + +export const AUTH_EU: ZohoAuth = { ...AUTH, data: { api_domain: 'https://www.zohoapis.eu' }, props: { location: 'zoho.eu' } }; diff --git a/packages/pieces/community/zoho-crm/test/regressions.test.ts b/packages/pieces/community/zoho-crm/test/regressions.test.ts new file mode 100644 index 000000000000..e6eb550bfba8 --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/regressions.test.ts @@ -0,0 +1,193 @@ +import { PropertyType } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addTagsToRecordAction } from '../src/lib/actions/add-tags-to-record'; +import { convertLeadAction } from '../src/lib/actions/convert-lead'; +import { createRecordAction } from '../src/lib/actions/create-record'; +import { getRecordAction } from '../src/lib/actions/get-record'; +import { updateRecordAction } from '../src/lib/actions/update-record'; +import { newRecordTrigger } from '../src/lib/triggers/new-record'; +import { updatedRecordTrigger } from '../src/lib/triggers/updated-record'; +import { AUTH, asPolling, call, enableTrigger, httpError, memoryStore, ok, propertyContext, runAction, runTrigger, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const CURSOR_KEY = 'zoho_poll_cursor'; +const CREATE_DEFAULTS = { additional_fields: undefined, triggers: undefined, skip_automation: false }; +const CONVERT_DEFAULTS = { + account_id: undefined, contact_id: undefined, overwrite: false, assign_to: undefined, notify_lead_owner: false, notify_new_entity_owner: false, + create_deal: false, deal_name: undefined, deal_stage: undefined, deal_closing_date: undefined, deal_amount: undefined, deal_pipeline: undefined, deal_extra: undefined, +}; +const UPDATE_DEFAULTS = { additional_fields: undefined, skip_automation: false }; +const T0 = Date.parse('2026-09-29T10:00:00+00:00'); +const PAGE_SIZE = 200; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('Array props sent as a JSON string (dynamic value toggle)', () => { + it('update_record clears every field named in a JSON-string Clear Fields list', async () => { + ok({ body: { fields: [{ api_name: 'Phone', data_type: 'phone' }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '111' } }] } }); + await runAction({ + action: updateRecordAction, + propsValue: { ...UPDATE_DEFAULTS, module: 'Leads', record_id: '111', fields: {}, clear_fields: ['["Phone", "Custom_Field__c"]'] }, + }); + expect(call(1).body).toEqual({ data: [{ Phone: null, Custom_Field__c: null }] }); + }); + + it('add_tags_to_record keeps a comma inside a tag name given as a JSON array', async () => { + ok({ body: { tags: [] } }); + ok({ body: { tags: [{ status: 'success', details: { id: '1' } }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '111' } }] } }); + await runAction({ action: addTagsToRecordAction, propsValue: { module: 'Leads', record_id: '111', tags: undefined, new_tags: ['["Q4, EMEA"]'] } }); + expect(call(1).body).toEqual({ tags: [{ name: 'Q4, EMEA' }] }); + expect(call(2).body.tags).toEqual([{ name: 'Q4, EMEA' }]); + }); + + it('create_record reads a multi-select picklist given as a JSON array string', async () => { + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Interests__c', data_type: 'multiselectpicklist' }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Interests__c: '["A", "B, C"]' } } }); + expect(call(1).body.data[0]).toEqual({ Last_Name: 'Doe', Interests__c: ['A', 'B, C'] }); + }); +}); + +describe('field metadata failures', () => { + it('create_record fails instead of sending unconverted values when the field list returns a 500', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 500, body: { code: 'INTERNAL_ERROR', message: 'boom' } })); + await expect( + runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Annual_Revenue: '10' } } }), + ).rejects.toThrow(/INTERNAL_ERROR/); + expect(sendRequest).toHaveBeenCalledTimes(1); + }); + + it('create_record still works with raw values when the user may not read field metadata', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 403, body: { code: 'NO_PERMISSION', message: 'permission denied' } })); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe' } } }); + expect(call(1).body).toEqual({ data: [{ Last_Name: 'Doe' }] }); + }); +}); + +describe('convert_lead pickers', () => { + it('offers accounts, contacts and owners as dropdowns', async () => { + const props = convertLeadAction.props; + expect(props.account_id.type).toBe(PropertyType.DROPDOWN); + expect(props.contact_id.type).toBe(PropertyType.DROPDOWN); + expect(props.assign_to.type).toBe(PropertyType.DROPDOWN); + }); + + it('lists every active user across pages for the owner dropdown', async () => { + ok({ body: { users: [{ id: '1', full_name: 'Ann', email: 'ann@x.com' }], info: { more_records: true } } }); + ok({ body: { users: [{ id: '2', full_name: 'Bob' }], info: { more_records: false } } }); + const state = await convertLeadAction.props.assign_to.options({ auth: AUTH }, propertyContext()); + expect(state.options).toEqual([ + { label: 'Ann (ann@x.com)', value: '1' }, + { label: 'Bob', value: '2' }, + ]); + expect(call(0).queryParams).toMatchObject({ type: 'ActiveUsers', page: '1' }); + expect(call(1).queryParams).toMatchObject({ page: '2' }); + }); +}); + +describe('convert_lead into an existing record', () => { + it('explains Zoho\'s matching rule when the account does not match the lead', async () => { + sendRequest.mockRejectedValueOnce( + httpError({ status: 400, body: { data: [{ code: 'INVALID_DATA', status: 'error', message: "Account data doesn't match perfectly with the Lead data", details: { api_name: 'id' } }] } }), + ); + await expect( + runAction({ action: convertLeadAction, propsValue: { ...CONVERT_DEFAULTS, lead_id: '1', account_id: '2' } }), + ).rejects.toThrow(/Account data doesn't match the lead\. Zoho links an existing account only when the lead's Company equals the account name/); + }); +}); + +describe('get_record output', () => { + it('describes the record id and times and exposes every field', () => { + const fields = getRecordAction.outputSchema?.fields ?? []; + expect(fields.map((f) => f.key)).toEqual(['id', 'Created_Time', 'Modified_Time', 'record']); + expect(fields[3]).toMatchObject({ value: '', dynamicKey: true }); + }); +}); + +describe('polling bursts larger than one poll can read', () => { + it('new_record emits every record of a 6,000-record burst across polls, once each', async () => { + const server = fakeLeads({ count: 6000 }); + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + const propsValue = { module: 'Leads', fields: ['Email'] }; + const first = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + server.prepend({ count: 3 }); + const second = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + const third = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + const fourth = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + const emitted = [...first, ...second, ...third, ...fourth].map(idOf); + expect(new Set(emitted).size).toBe(emitted.length); + expect(emitted).toHaveLength(6003); + expect(fourth).toEqual([]); + }); + + it('updated_record restarts from the top when Zoho rejects a stored page token, without re-emitting', async () => { + const server = fakeLeads({ count: 5400, modified: true }); + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + const propsValue = { module: 'Leads', fields: ['Email'], include_new: true }; + const first = await runTrigger({ trigger: asPolling(updatedRecordTrigger), propsValue, store }); + server.expireTokens(); + const rest = [ + ...(await runTrigger({ trigger: asPolling(updatedRecordTrigger), propsValue, store })), + ...(await runTrigger({ trigger: asPolling(updatedRecordTrigger), propsValue, store })), + ]; + const emitted = [...first, ...rest].map(idOf); + expect(first).toHaveLength(5000); + expect(new Set(emitted).size).toBe(5400); + expect(emitted).toHaveLength(5400); + }); +}); + +function idOf(record: unknown): string { + if (typeof record === 'object' && record !== null && 'id' in record && typeof record.id === 'string') { + return record.id; + } + throw new Error('record without id'); +} + +function fakeLeads({ count, modified = false }: { count: number; modified?: boolean }) { + const timeField = modified ? 'Modified_Time' : 'Created_Time'; + const make = ({ index }: { index: number }) => { + const time = new Date(T0 + (index + 1) * 1000).toISOString().replace('.000Z', '+00:00'); + return { id: `r${index}`, Created_Time: modified ? '2026-01-01T00:00:00+00:00' : time, Modified_Time: time }; + }; + let records = Array.from({ length: count }, (_, index) => make({ index })).reverse(); + let generation = 0; + let next = count; + sendRequest.mockImplementation(async (request: { url: string; queryParams: Record }) => { + const token = request.queryParams['page_token']; + const [tokenGeneration, tokenPage] = (token ?? '').split(':'); + if (token !== undefined && Number(tokenGeneration) !== generation) { + throw httpError({ status: 400, body: { code: 'INVALID_DATA', message: 'page_token expired' } }); + } + const page = token !== undefined ? Number(tokenPage) : Number(request.queryParams['page']); + if (token === undefined && page > 10) { + throw httpError({ status: 400, body: { code: 'INVALID_DATA', message: 'use page_token beyond 2000 records' } }); + } + const data = records.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const more = page * PAGE_SIZE < records.length; + expect(request.queryParams['sort_by']).toBe(timeField); + return { status: 200, headers: {}, body: { data, info: { more_records: more, next_page_token: more ? `${generation}:${page + 1}` : null } } }; + }); + return { + prepend: ({ count: extra }: { count: number }) => { + const fresh = Array.from({ length: extra }, (_, i) => make({ index: next + i })).reverse(); + next += extra; + records = [...fresh, ...records]; + }, + expireTokens: () => { + generation += 1; + }, + }; +} diff --git a/packages/pieces/community/zoho-crm/test/review-2.test.ts b/packages/pieces/community/zoho-crm/test/review-2.test.ts new file mode 100644 index 000000000000..74c7e238c937 --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/review-2.test.ts @@ -0,0 +1,177 @@ +import { Readable } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { convertLeadAction } from '../src/lib/actions/convert-lead'; +import { createRecordAction } from '../src/lib/actions/create-record'; +import { downloadAttachmentAtomic } from '../src/lib/actions/ai/related-attachments'; +import { newContact } from '../src/lib/triggers/new-contact'; +import { newRecordTrigger } from '../src/lib/triggers/new-record'; +import { AUTH, asPolling, call, captureWrites, enableTrigger, memoryStore, ok, propertyContext, runAction, runTrigger, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const CURSOR_KEY = 'zoho_poll_cursor'; +const T0 = Date.parse('2026-09-29T10:00:00+00:00'); +const CREATE_DEFAULTS = { additional_fields: undefined, triggers: undefined, skip_automation: false }; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('a second with more than 5,000 records that is still open when first read', () => { + it('emits records stamped later in that second, even though Zoho sorts them before the saved page token', async () => { + const second = T0 + 60_000; + const zoho = keysetZoho({ serverTime: second + 500 }); + zoho.add({ count: 5100, time: second }); + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + const propsValue = { module: 'Leads', fields: ['Email'] }; + const emitted = new Set(); + const first = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + first.forEach((r) => emitted.add(idOf(r))); + zoho.add({ count: 300, time: second }); + zoho.setServerTime(second + 120_000); + for (let poll = 0; poll < 4; poll++) { + const out = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + out.forEach((r) => emitted.add(idOf(r))); + } + expect(emitted.size).toBe(5400); + }); + + it('emits each record exactly once when the second had already closed at Zoho', async () => { + const second = T0 + 60_000; + const zoho = keysetZoho({ serverTime: second + 5_000 }); + zoho.add({ count: 5400, time: second }); + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + const propsValue = { module: 'Leads', fields: ['Email'] }; + const emitted: string[] = []; + for (let poll = 0; poll < 4; poll++) { + emitted.push(...(await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store })).map(idOf)); + } + expect(emitted).toHaveLength(5400); + expect(new Set(emitted).size).toBe(5400); + }); +}); + +describe('New Contact upgraded from 0.3.x', () => { + it('continues from the old lastPoll instead of skipping contacts created since then', async () => { + const { store, read } = memoryStore(); + await store.put('lastPoll', T0); + await enableTrigger({ trigger: asPolling(newContact), propsValue: { additional_fields: undefined }, store, isRepublish: true }); + expect(read(CURSOR_KEY)).toEqual({ time: T0 + 1, ids: [] }); + expect(read('lastPoll')).toBeUndefined(); + ok({ + body: { + data: [ + { id: 'new', Created_Time: new Date(T0 + 1000).toISOString().replace('.000Z', '+00:00') }, + { id: 'old', Created_Time: new Date(T0).toISOString().replace('.000Z', '+00:00') }, + ], + info: { more_records: false }, + }, + }); + const out = await runTrigger({ trigger: asPolling(newContact), propsValue: { additional_fields: undefined }, store }); + expect(out.map(idOf)).toEqual(['new']); + }); +}); + +describe('record pickers', () => { + it('say when only the 200 most recent records are listed', async () => { + ok({ body: { fields: [{ api_name: 'Account_Name' }] } }); + ok({ body: { data: [{ id: '1', Account_Name: 'Acme' }], info: { more_records: true } } }); + const state = await convertLeadAction.props.account_id.options({ auth: AUTH }, propertyContext()); + expect(state.options).toEqual([{ label: 'Acme (1)', value: '1' }]); + expect(state.placeholder).toMatch(/most recently modified records; map a record id/); + }); +}); + +describe('field values', () => { + it('refuses a date with trailing text and keeps the date part of a full date-time as written', async () => { + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Day__c', data_type: 'date' }] } }); + await expect( + runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Day__c: '2026-10-01abc' } } }), + ).rejects.toThrow(/yyyy-MM-dd/); + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Day__c', data_type: 'date' }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Day__c: '2026-10-01T01:00:00+03:00' } } }); + expect(call(2).body.data[0].Day__c).toBe('2026-10-01'); + }); + + it('sends long integers as the digit string Zoho expects and refuses more than 18 digits', async () => { + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Big__c', data_type: 'bigint' }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Big__c: '12345' } } }); + expect(call(1).body.data[0].Big__c).toBe('12345'); + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Big__c', data_type: 'bigint' }] } }); + await expect( + runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Big__c: '9007199254740993123' } } }), + ).rejects.toThrow(/18 digits/); + expect(sendRequest).toHaveBeenCalledTimes(3); + }); +}); + +describe('downloaded file names', () => { + it.each([ + ["attachment; filename*=UTF-8'en'..%2F..%2Fetc%2Fpasswd", 'passwd'], + ['attachment; filename="../../report\u0007.pdf"', 'report.pdf'], + ['attachment; filename=".."', 'attachment-2'], + ])('turns %s into a plain file name', async (disposition, expected) => { + ok({ body: Readable.from([Buffer.from('x')]), headers: { 'content-disposition': disposition } }); + const written = captureWrites(); + await runAction({ action: downloadAttachmentAtomic, propsValue: { module_api_name: 'Leads', record_id: '1', attachment_id: '2' }, files: written.files }); + expect(written.calls[0].fileName).toBe(expected); + }); +}); + +function keysetZoho({ serverTime }: { serverTime: number }) { + let records: { id: string; Created_Time: string; key: number; time: number }[] = []; + let nextId = 1000; + let now = serverTime; + const stamp = (time: number) => new Date(time).toISOString().replace('.000Z', '+00:00'); + const order = () => + [...records].sort((a, b) => (b.time !== a.time ? b.time - a.time : b.key - a.key)); + sendRequest.mockImplementation(async (request: { queryParams: Record }) => { + const perPage = Number(request.queryParams['per_page'] ?? 200); + const token = request.queryParams['page_token']; + const sorted = order(); + let start = 0; + if (token !== undefined) { + const [time, key] = token.split(':'); + start = sorted.findIndex((r) => r.time < Number(time) || (r.time === Number(time) && r.key < Number(key))); + if (start === -1) start = sorted.length; + } + const slice = sorted.slice(start, start + perPage); + const more = start + perPage < sorted.length; + const last = slice[slice.length - 1]; + return { + status: 200, + headers: { date: new Date(now).toUTCString() }, + body: { + data: slice.map((r) => ({ id: r.id, Created_Time: r.Created_Time })), + info: { more_records: more, next_page_token: more && last ? `${last.time}:${last.key}` : null }, + }, + }; + }); + return { + add: ({ count, time }: { count: number; time: number }) => { + const fresh = Array.from({ length: count }, () => { + nextId += 1; + return { id: `r${nextId}`, Created_Time: stamp(time), key: nextId, time }; + }); + records = [...records, ...fresh]; + }, + setServerTime: (time: number) => { + now = time; + }, + }; +} + +function idOf(record: unknown): string { + if (typeof record === 'object' && record !== null && 'id' in record && typeof record.id === 'string') { + return record.id; + } + throw new Error('record without id'); +} diff --git a/packages/pieces/community/zoho-crm/test/review-fixes.test.ts b/packages/pieces/community/zoho-crm/test/review-fixes.test.ts new file mode 100644 index 000000000000..685aaaa7cbc7 --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/review-fixes.test.ts @@ -0,0 +1,223 @@ +import { Readable } from 'node:stream'; +import { PropertyType } from '@activepieces/pieces-framework'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { addTagsToRecordAction } from '../src/lib/actions/add-tags-to-record'; +import { convertLeadAction } from '../src/lib/actions/convert-lead'; +import { createRecordAction } from '../src/lib/actions/create-record'; +import { readFile } from '../src/lib/actions/read-file'; +import { updateRecordAction } from '../src/lib/actions/update-record'; +import { upsertRecordAction } from '../src/lib/actions/upsert-record'; +import { downloadAttachmentAtomic, listRelatedRecordsAtomic } from '../src/lib/actions/ai/related-attachments'; +import { listNotesAtomic } from '../src/lib/actions/ai/notes-tags'; +import { updateRecordAtomic, upsertRecordAtomic } from '../src/lib/actions/ai/records'; +import { newContact } from '../src/lib/triggers/new-contact'; +import { newRecordTrigger } from '../src/lib/triggers/new-record'; +import { zohoCrm } from '../src/index'; +import { asPolling, call, captureWrites, enableTrigger, httpError, memoryStore, ok, propertyContext, runAction, runTrigger, sendRequest, AUTH } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const CURSOR_KEY = 'zoho_poll_cursor'; +const T0 = Date.parse('2026-09-29T10:00:00+00:00'); +const STORE_VALUE_MAX_BYTES = 512 * 1024; +const CREATE_DEFAULTS = { additional_fields: undefined, triggers: undefined, skip_automation: false }; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('polling: records that share one second', () => { + it('emits every record of a 12,000-record import stamped with the same second, once each, and keeps the cursor under the store limit', async () => { + const stamp = new Date(T0 + 5000).toISOString().replace('.000Z', '+00:00'); + const records = Array.from({ length: 12000 }, (_, i) => ({ id: `s${i}`, Created_Time: stamp, Email: null })); + serveRecords({ records }); + const { store, read } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + const propsValue = { module: 'Leads', fields: ['Email'] }; + const emitted: string[] = []; + const cursorSizes: number[] = []; + for (let poll = 0; poll < 4; poll++) { + const out = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + emitted.push(...out.map(idOf)); + cursorSizes.push(JSON.stringify(read(CURSOR_KEY)).length); + } + expect(emitted).toHaveLength(12000); + expect(new Set(emitted).size).toBe(12000); + expect(Math.max(...cursorSizes)).toBeLessThan(STORE_VALUE_MAX_BYTES); + }); + + it('seeds the cursor at a whole second of Zoho\'s clock, so records created in the enable second are not skipped', async () => { + vi.spyOn(Date, 'now').mockReturnValue(T0 + 4123); + ok({ body: undefined, status: 204, headers: { date: new Date(T0 + 1000).toUTCString() } }); + const { store, read } = memoryStore(); + await enableTrigger({ trigger: asPolling(newRecordTrigger), propsValue: { module: 'Leads', fields: undefined }, store }); + expect(read(CURSOR_KEY)).toEqual({ time: T0 + 1000, ids: [] }); + }); +}); + +describe('triggers: fields and metadata', () => { + it('refuses more than 47 picked fields when the flow is published', async () => { + const { store } = memoryStore(); + const fields = Array.from({ length: 48 }, (_, i) => `F${i}`); + await expect(enableTrigger({ trigger: asPolling(newRecordTrigger), propsValue: { module: 'Leads', fields }, store })).rejects.toThrow(/at most 47/); + }); + + it('reads the default field list once, not on every poll', async () => { + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + ok({ body: { fields: [{ api_name: 'Last_Name' }] } }); + ok({ body: undefined, status: 204 }); + ok({ body: undefined, status: 204 }); + const propsValue = { module: 'Leads', fields: undefined }; + await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue, store }); + const fieldCalls = sendRequest.mock.calls.filter(([request]) => String(request.url).endsWith('/settings/fields')); + expect(fieldCalls).toHaveLength(1); + }); + + it('New Contact pages back to its last check instead of reading only the newest 200', async () => { + const records = Array.from({ length: 450 }, (_, i) => ({ id: `c${i}`, Created_Time: new Date(T0 + (450 - i) * 1000).toISOString().replace('.000Z', '+00:00') })); + serveRecords({ records }); + const { store } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + await store.put('lastPoll', T0); + const out = await runTrigger({ trigger: asPolling(newContact), propsValue: { additional_fields: undefined }, store }); + expect(out).toHaveLength(450); + }); +}); + +describe('Read File and Download Attachment', () => { + it.each([ + 'https://www.zohoapis.com/crm/v7/functions/fn/actions/execute?auth_type=apikey&zapikey=attacker', + 'https://files.zohopublic.com/public/x.zip', + 'https://www.zohoapis.eu/crm/v8/org', + ])('refuses %s for a US connection before any request', async (url) => { + await expect(runAction({ action: readFile, propsValue: { url } })).rejects.toThrow(/Refusing to send the Zoho CRM token/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('Read File streams into the file store instead of buffering', async () => { + ok({ body: Readable.from([Buffer.from('zip')]) }); + const written = captureWrites(); + await runAction({ action: readFile, propsValue: { url: 'https://download-accl.zoho.com/v2/crm/1/backup/f.zip' }, files: written.files }); + expect(call(0).responseType).toBe('stream'); + expect(written.calls).toEqual([{ fileName: 'f.zip', streamed: true, content: 'zip' }]); + }); + + it('Download Attachment streams, reports the size, and destroys the source when the store refuses the file', async () => { + ok({ body: Readable.from([Buffer.from('%PDF-1')]), headers: { 'content-disposition': 'attachment; filename="p.pdf"', 'content-type': 'application/pdf' } }); + const written = captureWrites(); + const propsValue = { module_api_name: 'Leads', record_id: '1', attachment_id: '2' }; + const out = await runAction({ action: downloadAttachmentAtomic, propsValue, files: written.files }); + expect(call(0)).toMatchObject({ url: 'https://www.zohoapis.com/crm/v8/Leads/1/Attachments/2', responseType: 'stream', followRedirects: false }); + expect(out).toEqual({ file: 'file://stored', file_name: 'p.pdf', size: 6, content_type: 'application/pdf' }); + const source = Readable.from([Buffer.from('a'), Buffer.from('b')]); + ok({ body: source }); + const tooBig = { write: async () => Promise.reject(new Error('File size exceeds the limit')), upload: vi.fn() }; + await expect(runAction({ action: downloadAttachmentAtomic, propsValue, files: tooBig })).rejects.toThrow(/exceeds the limit/); + expect(source.destroyed).toBe(true); + }); +}); + +describe('Custom API Call', () => { + it('does not send the token to a full URL on another host', async () => { + const action = zohoCrm.actions()['custom_api_call']; + const propsValue = { url: { url: 'https://attacker.example/collect' }, method: 'GET', headers: {}, queryParams: {} }; + await expect(runAction({ action, propsValue })).rejects.toThrow(/only to https:\/\/www\.zohoapis\.com/); + }); +}); + +describe('field types', () => { + it('asks for dates as yyyy-MM-dd text, so the engine does not shift them to UTC', async () => { + ok({ body: { fields: [{ api_name: 'Birthday__c', data_type: 'date' }, { api_name: 'Big__c', data_type: 'bigint' }] } }); + const props = await createRecordAction.props.fields.props({ auth: AUTH, module: 'Leads' }, propertyContext()); + expect(props['Birthday__c'].type).toBe(PropertyType.SHORT_TEXT); + expect(convertLeadAction.props.deal_closing_date.type).toBe(PropertyType.SHORT_TEXT); + }); + + it('sends a long integer beyond 2^53 with every digit', async () => { + ok({ body: { fields: [{ api_name: 'Last_Name', data_type: 'text' }, { api_name: 'Big__c', data_type: 'bigint' }] } }); + ok({ body: { data: [{ status: 'success', details: { id: '9' } }] }, status: 201 }); + await runAction({ action: createRecordAction, propsValue: { ...CREATE_DEFAULTS, module: 'Leads', fields: { Last_Name: 'Doe', Big__c: '900719925474099312' } } }); + expect(call(1).body.data[0].Big__c).toBe('900719925474099312'); + }); + + it('refuses an impossible date instead of sending it', async () => { + const propsValue = { + lead_id: '1', account_id: undefined, contact_id: undefined, overwrite: false, assign_to: undefined, notify_lead_owner: false, notify_new_entity_owner: false, + create_deal: true, deal_name: 'D', deal_stage: 'Qualification', deal_closing_date: '2026-13-01', deal_amount: undefined, deal_pipeline: undefined, deal_extra: undefined, + }; + await expect(runAction({ action: convertLeadAction, propsValue })).rejects.toThrow(/yyyy-MM-dd/); + expect(sendRequest).not.toHaveBeenCalled(); + }); +}); + +describe('metadata and inputs', () => { + it('does not claim update and upsert are idempotent', () => { + for (const action of [updateRecordAction, upsertRecordAction, updateRecordAtomic, upsertRecordAtomic]) { + expect(action.aiMetadata?.idempotent).toBe(false); + } + }); + + it('refuses pages past the 2,000-item window before any request', async () => { + await expect( + runAction({ action: listNotesAtomic, propsValue: { parent_module: 'Leads', parent_id: '1', page: 11, per_page: 200 } }), + ).rejects.toThrow(/2,000/); + await expect( + runAction({ + action: listRelatedRecordsAtomic, + propsValue: { module_api_name: 'Accounts', record_id: '1', related_list_api_name: 'Contacts', fields: ['id'], page: 11, per_page: 200, page_token: undefined }, + }), + ).rejects.toThrow(/2,000/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('checks the tag count before creating any tag', async () => { + const newTags = Array.from({ length: 11 }, (_, i) => `T${i}`); + await expect(runAction({ action: addTagsToRecordAction, propsValue: { module: 'Leads', record_id: '1', tags: undefined, new_tags: newTags } })).rejects.toThrow(/at most 10/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('keeps the users already read when a later page of the Owner dropdown fails', async () => { + ok({ body: { users: [{ id: '1', full_name: 'Ann' }], info: { more_records: true } } }); + sendRequest.mockRejectedValueOnce(httpError({ status: 500, body: { code: 'INTERNAL_ERROR', message: 'boom' } })); + const state = await convertLeadAction.props.assign_to.options({ auth: AUTH }, propertyContext()); + expect(state.options).toEqual([{ label: 'Ann', value: '1' }]); + expect(state.disabled).toBe(false); + expect(state.placeholder).toMatch(/first 1 users/); + }); + + it('names the parameter Zoho complains about', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 400, body: { code: 'INVALID_DATA', message: 'invalid data', details: { param_name: 'sort_by' } } })); + await expect(runAction({ action: listNotesAtomic, propsValue: { parent_module: undefined, parent_id: undefined, page: undefined, per_page: undefined } })).rejects.toThrow(/parameter: sort_by/); + }); +}); + +function serveRecords({ records, serverTime = T0 + 3_600_000 }: { records: Record[]; serverTime?: number }) { + sendRequest.mockImplementation(async (request: { queryParams: Record }) => { + const token = request.queryParams['page_token']; + const perPage = Number(request.queryParams['per_page'] ?? 200); + const page = token !== undefined ? Number(token) : Number(request.queryParams['page'] ?? 1); + if (token === undefined && page * perPage > 2000) { + throw httpError({ status: 400, body: { code: 'DISCRETE_PAGINATION_LIMIT_EXCEEDED', message: 'use page_token' } }); + } + const data = records.slice((page - 1) * perPage, page * perPage); + const more = page * perPage < records.length; + return { status: 200, headers: { date: new Date(serverTime).toUTCString() }, body: { data, info: { more_records: more, next_page_token: more ? String(page + 1) : null } } }; + }); +} + +function idOf(record: unknown): string { + if (typeof record === 'object' && record !== null && 'id' in record && typeof record.id === 'string') { + return record.id; + } + throw new Error('record without id'); +} diff --git a/packages/pieces/community/zoho-crm/test/zoho-crm.test.ts b/packages/pieces/community/zoho-crm/test/zoho-crm.test.ts new file mode 100644 index 000000000000..0fe362cb2ec9 --- /dev/null +++ b/packages/pieces/community/zoho-crm/test/zoho-crm.test.ts @@ -0,0 +1,464 @@ +import { PropertyType } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Readable } from 'node:stream'; +import { tokenHosts, zohoTokenUrl } from '../src/lib/common/host-guard'; +import { readFile } from '../src/lib/actions/read-file'; +import { contactFieldsParam, newContact } from '../src/lib/triggers/new-contact'; +import { newRecordTrigger } from '../src/lib/triggers/new-record'; +import { isUpdateOnly, updatedRecordTrigger } from '../src/lib/triggers/updated-record'; +import { collectSince, CURSOR_KEY, PollCursor, ZohoRecord } from '../src/lib/common/polling'; +import { buildFieldProps, buildRecordPayload, convertFieldValue, FIELDS_JSON_KEY, fieldsJsonFallbackProp, isApiReadOnly } from '../src/lib/common/fields'; +import { defaultFieldSelection, ZohoField } from '../src/lib/common/metadata'; +import { flattenAttachment } from '../src/lib/common/flatten'; +import { buildMultipart, deleteRecord, updateRecord } from '../src/lib/common/records'; +import { customApiAuthHeaders, getApiDomain, stringList, toZohoError, unwrapWriteItem } from '../src/lib/common/client'; +import { createRecordAction } from '../src/lib/actions/create-record'; +import { updateRecordAction } from '../src/lib/actions/update-record'; +import { zohoCrm } from '../src/index'; +import { AUTH, asPolling, call, captureWrites, enableTrigger, httpError, memoryStore, ok, propertyContext, runAction, runTrigger, sendRequest } from './helpers'; + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const actual = await importOriginal(); + const { mockHttpClient } = await import('./helpers'); + return { ...actual, ...mockHttpClient() }; +}); + +const LEAD_FIELDS: ZohoField[] = [ + { api_name: 'Last_Name', data_type: 'text', system_mandatory: true, field_label: 'Last Name' }, + { api_name: 'Email', data_type: 'email' }, + { api_name: 'Annual_Revenue', data_type: 'currency' }, + { api_name: 'Email_Opt_Out', data_type: 'boolean' }, + { api_name: 'Birth_Date__c', data_type: 'date' }, + { api_name: 'Next_Call__c', data_type: 'datetime' }, + { api_name: 'Lead_Status', data_type: 'picklist', pick_list_values: [{ display_value: '-None-', actual_value: '-None-' }, { display_value: 'Contacted', actual_value: 'Contacted' }] }, + { api_name: 'Interests__c', data_type: 'multiselectpicklist', pick_list_values: [{ actual_value: 'A' }, { actual_value: 'B' }] }, + { api_name: 'Account__c', data_type: 'lookup', lookup: { module: { api_name: 'Accounts' } } }, + { api_name: 'Owner', data_type: 'ownerlookup' }, + { api_name: 'Notes__c', data_type: 'textarea' }, + { api_name: 'Score__c', data_type: 'formula', read_only: true }, + { api_name: 'Items', data_type: 'subform' }, + { api_name: 'Created_Time', data_type: 'datetime', read_only: true }, + { api_name: 'Hidden__c', data_type: 'text', visible: false }, +]; + +beforeEach(() => { + sendRequest.mockReset(); +}); + +describe('token host guard', () => { + const US = tokenHosts('https://www.zohoapis.com'); + it.each([ + 'https://www.zohoapis.com/crm/v8/org', + 'https://www.zohoapis.com/crm/bulk/v2/read/1/result', + 'https://download-accl.zoho.com/v2/crm/123/backup/1/file.zip', + 'https://download.zoho.com/v2/crm/123/backup/1/file.zip', + ])('allows %s for a US connection', (url) => { + expect(zohoTokenUrl({ raw: url, hosts: US })).not.toBeNull(); + }); + + it.each([ + 'https://www.zohoapis.com/crm/v7/functions/fn/actions/execute?auth_type=apikey&zapikey=x', + 'https://www.zohoapis.com/crm/v7/%66unctions/fn/actions/execute', + 'https://www.zohoapis.com/oauth/v2/token', + 'https://accounts.zoho.com/oauth/v2/token', + 'https://www.zohoapis.eu/crm/v8/org', + 'https://download-accl.zoho.eu/v2/crm/1/backup/f.zip', + 'https://files.zohopublic.com/crm/x', + 'https://x.zohoexternal.com/crm/x', + 'https://zoho.com.evil.io/crm/x', + 'https://www.zohoapis.com.attacker.net/crm/x', + 'http://www.zohoapis.com/crm/v8/org', + 'https://user:pw@www.zohoapis.com/crm/v8/org', + 'https://www.zohoapis.com:8443/crm/v8/org', + '//www.zohoapis.com/crm/v8/org', + 'not a url', + ])('refuses %s for a US connection', (url) => { + expect(zohoTokenUrl({ raw: url, hosts: US })).toBeNull(); + }); + + it('builds the list from the connection data centre', () => { + expect(tokenHosts('https://www.zohoapis.eu')).toEqual({ apiHost: 'www.zohoapis.eu', downloadHosts: ['download-accl.zoho.eu', 'download.zoho.eu'] }); + expect(tokenHosts('https://www.zohoapis.ca').downloadHosts).toEqual(['download-accl.zohocloud.ca', 'download.zohocloud.ca']); + expect(tokenHosts('https://www.zohoapis.com.au').downloadHosts).toEqual(['download-accl.zoho.com.au', 'download.zoho.com.au']); + }); +}); + +describe('read-file download', () => { + it('refuses a non-Zoho URL before any request', async () => { + await expect(runAction({ action: readFile, propsValue: { url: 'https://attacker.example/steal' } })).rejects.toThrow(/Refusing to send the Zoho CRM token/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('streams from the Zoho host with the token and no automatic redirects', async () => { + ok({ body: Readable.from([Buffer.from('ab'), Buffer.from('c')]) }); + const written = captureWrites(); + const out = await runAction({ action: readFile, propsValue: { url: 'https://download-accl.zoho.com/v2/crm/1/backup/Leads_001.zip?x=1' }, files: written.files }); + expect(sendRequest).toHaveBeenCalledTimes(1); + expect(call(0)).toMatchObject({ headers: { Authorization: 'Zoho-oauthtoken tok-123' }, followRedirects: false, responseType: 'stream' }); + expect(out).toBe('file://stored'); + expect(written.calls).toEqual([{ fileName: 'Leads_001.zip', streamed: true, content: 'abc' }]); + }); + + it('follows redirects only between this connection\'s Zoho CRM file hosts, keeping the token there', async () => { + ok({ body: undefined, status: 302, headers: { location: 'https://download.zoho.com/v2/crm/1/signed?x=1' } }); + ok({ body: Buffer.from('bytes') }); + const written = captureWrites(); + await runAction({ action: readFile, propsValue: { url: 'https://download-accl.zoho.com/v2/crm/1/backup/f.zip' }, files: written.files }); + expect(sendRequest).toHaveBeenCalledTimes(2); + expect(call(1)).toMatchObject({ url: 'https://download.zoho.com/v2/crm/1/signed?x=1', headers: { Authorization: 'Zoho-oauthtoken tok-123' } }); + expect(written.calls[0].content).toBe('bytes'); + sendRequest.mockReset(); + ok({ body: undefined, status: 302, headers: { location: 'http://download.zoho.com/v2/crm/1/f.zip' } }); + await expect(runAction({ action: readFile, propsValue: { url: 'https://download-accl.zoho.com/v2/crm/1/backup/f.zip' } })).rejects.toThrow(/non-https/); + }); +}); + +describe('new_contact fixes', () => { + it('requests the same 46 fields when no extras are chosen', () => { + const fields = contactFieldsParam(undefined).split(','); + expect(fields).toHaveLength(46); + expect(fields[0]).toBe('Owner'); + expect(fields[45]).toBe('Account_Name'); + }); + + it('adds at most 4 extras, deduped', () => { + const fields = contactFieldsParam(['Email', 'C1__c', 'C2__c', 'C3__c', 'C4__c', 'C5__c', 'C1__c']).split(','); + expect(fields).toHaveLength(50); + expect(fields.slice(46)).toEqual(['C1__c', 'C2__c', 'C3__c', 'C4__c']); + }); + + it('sends per_page (not perPage) and survives a 204', async () => { + const { store } = memoryStore(); + await store.put('lastPoll', 0); + ok({ body: undefined, status: 204 }); + const out = await runTrigger({ trigger: asPolling(newContact), propsValue: { additional_fields: undefined }, store }); + expect(out).toEqual([]); + expect(call(0).queryParams.per_page).toBe('200'); + expect(call(0).queryParams.perPage).toBeUndefined(); + }); +}); + +describe('polling cursor', () => { + const T0 = Date.parse('2026-09-29T10:00:00+00:00'); + const rec = ({ id, secondsAfter }: { id: string; secondsAfter: number }): ZohoRecord => ({ + id, + Created_Time: new Date(T0 + secondsAfter * 1000).toISOString().replace('.000Z', '+00:00'), + }); + const onePage = ({ records }: { records: ZohoRecord[] }) => async () => ({ records, more: false }); + + it('stops at the checkpoint and returns oldest first', async () => { + const r = await collectSince({ + cursor: { time: T0, ids: [] }, + timeField: 'Created_Time', + fetchPage: async () => ({ records: [rec({ id: '3', secondsAfter: 30 }), rec({ id: '2', secondsAfter: 20 }), rec({ id: '1', secondsAfter: -5 })], more: true, nextPageToken: 't2' }), + }); + expect(r.records.map((x) => x.id)).toEqual(['2', '3']); + expect(r.cursor).toEqual({ time: T0 + 30000, ids: ['3'] }); + expect(r.truncated).toBe(false); + }); + + it('keeps same-second records not emitted yet, skips those already emitted', async () => { + const cursor: PollCursor = { time: T0, ids: ['a'] }; + const r = await collectSince({ cursor, timeField: 'Created_Time', fetchPage: onePage({ records: [rec({ id: 'b', secondsAfter: 0 }), rec({ id: 'a', secondsAfter: 0 })] }) }); + expect(r.records.map((x) => x.id)).toEqual(['b']); + expect(r.cursor).toEqual({ time: T0, ids: ['a', 'b'] }); + }); + + it('pages with next_page_token until it passes the checkpoint', async () => { + const fetchPage = vi.fn(async ({ pageToken }: { pageToken?: string }) => + pageToken === undefined + ? { records: [rec({ id: '4', secondsAfter: 40 }), rec({ id: '3', secondsAfter: 30 })], more: true, nextPageToken: 't2' } + : { records: [rec({ id: '2', secondsAfter: 20 }), rec({ id: '1', secondsAfter: -1 })], more: true, nextPageToken: 't3' }, + ); + const r = await collectSince({ cursor: { time: T0, ids: [] }, timeField: 'Created_Time', fetchPage }); + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(fetchPage.mock.calls[1][0]).toEqual({ pageToken: 't2' }); + expect(r.records.map((x) => x.id)).toEqual(['2', '3', '4']); + }); + + it('keeps a backlog instead of skipping records when the page budget runs out', async () => { + const fetchPage = vi.fn(async ({ pageToken }: { pageToken?: string }) => { + const page = pageToken === undefined ? 1 : Number(pageToken); + return { records: [rec({ id: `p${page}`, secondsAfter: 1000 - page })], more: true, nextPageToken: String(page + 1) }; + }); + const r = await collectSince({ cursor: { time: T0, ids: [] }, timeField: 'Created_Time', fetchPage, maxPages: 3 }); + expect(fetchPage).toHaveBeenCalledTimes(3); + expect(r.truncated).toBe(true); + expect(r.records.map((x) => x.id)).toEqual(['p3', 'p2', 'p1']); + expect(r.cursor).toEqual({ + time: T0, + ids: [], + backlog: { pageToken: '4', top: { time: T0 + 999000, ids: ['p1'] }, floor: { time: T0 + 997000, ids: ['p3'] } }, + }); + }); + + it('fails loudly when Zoho reports more records without a page token', async () => { + await expect( + collectSince({ cursor: { time: T0, ids: [] }, timeField: 'Created_Time', fetchPage: async () => ({ records: [rec({ id: '1', secondsAfter: 5 })], more: true }) }), + ).rejects.toThrow(/next_page_token/); + }); + + it('leaves the cursor alone when nothing is new', async () => { + const cursor = { time: T0, ids: ['x'] }; + const r = await collectSince({ cursor, timeField: 'Created_Time', fetchPage: onePage({ records: [] }) }); + expect(r.cursor).toBe(cursor); + }); + + it('new_record trigger: sorts by Created_Time, stores the cursor, re-poll emits nothing', async () => { + const { store, read } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + ok({ body: { fields: [{ api_name: 'Last_Name' }, { api_name: 'Custom__c' }] } }); + ok({ body: { data: [rec({ id: '2', secondsAfter: 20 }), rec({ id: '1', secondsAfter: 10 })], info: { more_records: false } } }); + const out = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue: { module: 'Leads', fields: undefined }, store }); + expect(out.map(idOf)).toEqual(['1', '2']); + const q = call(1).queryParams; + expect(call(1).url).toBe('https://www.zohoapis.com/crm/v8/Leads'); + expect(q).toMatchObject({ sort_by: 'Created_Time', sort_order: 'desc', per_page: '200', page: '1' }); + expect(q.fields.split(',')).toEqual(expect.arrayContaining(['id', 'Created_Time', 'Modified_Time', 'Last_Name', 'Custom__c'])); + expect(read(CURSOR_KEY)).toEqual({ time: T0 + 20000, ids: ['2'] }); + ok({ body: { data: [rec({ id: '2', secondsAfter: 20 }), rec({ id: '1', secondsAfter: 10 })], info: { more_records: false } } }); + const again = await runTrigger({ trigger: asPolling(newRecordTrigger), propsValue: { module: 'Leads', fields: undefined }, store }); + expect(again).toEqual([]); + expect(sendRequest).toHaveBeenCalledTimes(3); + expect(call(2).url).toBe('https://www.zohoapis.com/crm/v8/Leads'); + }); + + it('updated_record skips records only created, unless include_new', async () => { + const created = { id: '1', Created_Time: '2026-09-29T10:00:05+00:00', Modified_Time: '2026-09-29T10:00:05+00:00' }; + const edited = { id: '2', Created_Time: '2026-09-28T10:00:00+00:00', Modified_Time: '2026-09-29T10:00:06+00:00' }; + expect(isUpdateOnly(created)).toBe(false); + expect(isUpdateOnly(edited)).toBe(true); + const { store, read } = memoryStore(); + await store.put(CURSOR_KEY, { time: T0, ids: [] }); + ok({ body: { data: [edited, created], info: { more_records: false } } }); + const out = await runTrigger({ trigger: asPolling(updatedRecordTrigger), propsValue: { module: 'Leads', fields: ['Email'], include_new: false }, store }); + expect(out.map(idOf)).toEqual(['2']); + expect(call(0).queryParams.sort_by).toBe('Modified_Time'); + expect(read(CURSOR_KEY)).toEqual({ time: Date.parse('2026-09-29T10:00:06+00:00'), ids: ['2'] }); + }); + + it('onEnable keeps an existing cursor on republish', async () => { + const { store, read } = memoryStore(); + await store.put(CURSOR_KEY, { time: 5, ids: [] }); + await enableTrigger({ trigger: asPolling(newRecordTrigger), propsValue: { module: 'Leads', fields: undefined }, store, isRepublish: true }); + expect(read(CURSOR_KEY)).toEqual({ time: 5, ids: [] }); + }); +}); + +describe('field mapping', () => { + it('maps data types to props and skips non-writable fields', () => { + const props = buildFieldProps({ fields: LEAD_FIELDS, mode: 'create' }); + expect(Object.keys(props)).toEqual([ + 'Last_Name', 'Email', 'Annual_Revenue', 'Email_Opt_Out', 'Birth_Date__c', 'Next_Call__c', + 'Lead_Status', 'Interests__c', 'Account__c', 'Owner', 'Notes__c', + ]); + expect(props['Last_Name'].required).toBe(true); + expect(props['Annual_Revenue'].type).toBe('NUMBER'); + expect(props['Email_Opt_Out'].type).toBe('STATIC_DROPDOWN'); + expect(props['Birth_Date__c'].type).toBe('SHORT_TEXT'); + expect(props['Next_Call__c'].type).toBe('DATE_TIME'); + expect(props['Interests__c'].type).toBe('STATIC_MULTI_SELECT_DROPDOWN'); + expect(props['Notes__c'].type).toBe('LONG_TEXT'); + const status = props['Lead_Status']; + if (status.type !== PropertyType.STATIC_DROPDOWN) { + throw new Error('Lead_Status is not a static dropdown'); + } + expect(status.options.options.map((o) => o.value)).toEqual(['Contacted']); + }); + + it('marks nothing required on update', () => { + const props = buildFieldProps({ fields: LEAD_FIELDS, mode: 'update' }); + expect(Object.values(props).every((p) => p.required === false)).toBe(true); + }); + + it('converts values to the Zoho format', () => { + const convert = ({ api, value }: { api: string; value: unknown }) => convertFieldValue({ field: LEAD_FIELDS.find((x) => x.api_name === api), value }); + expect(convert({ api: 'Annual_Revenue', value: '1200.5' })).toBe(1200.5); + expect(convert({ api: 'Email_Opt_Out', value: 'false' })).toBe(false); + expect(convert({ api: 'Birth_Date__c', value: '2026-10-01T00:00:00.000Z' })).toBe('2026-10-01'); + expect(convert({ api: 'Next_Call__c', value: '2026-10-01T09:00:00.000Z' })).toBe('2026-10-01T09:00:00+00:00'); + expect(convert({ api: 'Next_Call__c', value: '2026-10-01T09:00:00+02:00' })).toBe('2026-10-01T09:00:00+02:00'); + expect(convert({ api: 'Account__c', value: '5725767000000524157' })).toEqual({ id: '5725767000000524157' }); + expect(convert({ api: 'Interests__c', value: 'A, B' })).toEqual(['A', 'B']); + expect(() => convert({ api: 'Annual_Revenue', value: 'abc' })).toThrow(/number/); + expect(convertFieldValue({ field: undefined, value: 'raw' })).toBe('raw'); + }); + + it('keeps 19-digit ids as strings', () => { + const lookup = LEAD_FIELDS.find((x) => x.api_name === 'Account__c'); + expect(convertFieldValue({ field: lookup, value: '9007199254740993123' })).toEqual({ id: '9007199254740993123' }); + }); + + it('falls back to a JSON "fields" prop and reads it back', () => { + const props = fieldsJsonFallbackProp({ reason: 'HTTP 500', mode: 'create' }); + expect(Object.keys(props)).toEqual([FIELDS_JSON_KEY]); + expect(props[FIELDS_JSON_KEY].type).toBe('JSON'); + const payload = buildRecordPayload({ dynamicValues: { [FIELDS_JSON_KEY]: { Last_Name: 'Doe' } }, fields: undefined }); + expect(payload).toEqual({ Last_Name: 'Doe' }); + }); + + it('dynamic props fall back when metadata fails to load', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 500, body: { code: 'INTERNAL_ERROR', message: 'boom' } })); + const props = await createRecordAction.props.fields.props({ auth: AUTH, module: 'Leads' }, propertyContext()); + expect(Object.keys(props)).toEqual([FIELDS_JSON_KEY]); + }); +}); + +describe('partial updates', () => { + it('omits blank values, sends clears as null, dedicated props win over extra JSON', () => { + const payload = buildRecordPayload({ + dynamicValues: { Email: 'new@x.com', Lead_Status: undefined, Notes__c: '', Interests__c: [], Email_Opt_Out: 'false' }, + fields: LEAD_FIELDS, + extraFields: { Email: 'old@x.com', Custom__c: 'k' }, + clearFields: ['Phone'], + }); + expect(payload).toEqual({ Email: 'new@x.com', Custom__c: 'k', Email_Opt_Out: false, Phone: null }); + }); + + it('update_record sends only the set fields via PUT /{module}/{id}', async () => { + ok({ body: { fields: LEAD_FIELDS } }); + ok({ body: { data: [{ code: 'SUCCESS', status: 'success', message: 'record updated', details: { id: '111', Modified_Time: '2026-09-29T10:00:00+02:00' } }] } }); + const out = await runAction({ + action: updateRecordAction, + propsValue: { module: 'Leads', record_id: '111', fields: { Email: 'n@x.com', Last_Name: '' }, clear_fields: [], additional_fields: undefined, skip_automation: false }, + }); + expect(call(1).method).toBe('PUT'); + expect(call(1).url).toBe('https://www.zohoapis.com/crm/v8/Leads/111'); + expect(call(1).body).toEqual({ data: [{ Email: 'n@x.com' }] }); + expect(out).toMatchObject({ id: '111', status: 'success', module: 'Leads' }); + }); + + it('refuses an empty update', async () => { + await expect(updateRecord({ auth: AUTH, module: 'Leads', id: '1', record: {} })).rejects.toThrow(/no fields/); + expect(sendRequest).not.toHaveBeenCalled(); + }); + + it('adds $append_values for multi-select appends', async () => { + ok({ body: { data: [{ status: 'success', details: { id: '1' } }] } }); + await updateRecord({ auth: AUTH, module: 'Leads', id: '1', record: { Interests__c: ['C'] }, appendMultiSelect: ['Interests__c'] }); + expect(call(0).body.data[0]).toEqual({ Interests__c: ['C'], $append_values: { Interests__c: true } }); + }); +}); + +describe('writes and errors', () => { + it('create_record passes trigger [] when automation is skipped', async () => { + ok({ body: { fields: LEAD_FIELDS } }); + ok({ body: { data: [{ code: 'SUCCESS', status: 'success', details: { id: '9', Created_By: { id: '5', name: 'Jane' } } }] }, status: 201 }); + const out = await runAction({ action: createRecordAction, propsValue: { module: 'Leads', fields: { Last_Name: 'Doe', Annual_Revenue: '10' }, additional_fields: undefined, triggers: undefined, skip_automation: true } }); + expect(call(1).body).toEqual({ data: [{ Last_Name: 'Doe', Annual_Revenue: 10 }], trigger: [] }); + expect(out).toMatchObject({ id: '9', created_by_name: 'Jane' }); + }); + + it('throws on a per-record error inside a 207', () => { + expect(() => unwrapWriteItem({ body: { data: [{ code: 'INVALID_DATA', status: 'error', message: 'invalid data', details: { api_name: 'Email' } }] } })).toThrow( + /INVALID_DATA.*field: Email/, + ); + }); + + it('maps OAUTH_SCOPE_MISMATCH to a reconnect hint', () => { + const e = toZohoError(httpError({ status: 401, body: { code: 'OAUTH_SCOPE_MISMATCH', message: 'invalid oauth scope' } })); + expect(e.message).toMatch(/OAUTH_SCOPE_MISMATCH \(HTTP 401\).*scope/); + }); + + it('names a 429 without a Zoho code as TOO_MANY_REQUESTS', () => { + const e = toZohoError(httpError({ status: 429, body: { message: 'slow down' } })); + expect(e.message).toMatch(/^Zoho CRM TOO_MANY_REQUESTS \(HTTP 429\): slow down/); + }); + + it('custom API call refuses a connection without api_domain, else sends Zoho-oauthtoken', () => { + expect(() => customApiAuthHeaders({ auth: { access_token: 't', data: {} }, url: '/org' })).toThrow(/Reconnect/); + const eu = { access_token: 't', data: { api_domain: 'https://www.zohoapis.eu' } }; + expect(customApiAuthHeaders({ auth: eu, url: '/org' })).toEqual({ Authorization: 'Zoho-oauthtoken t' }); + expect(customApiAuthHeaders({ auth: eu, url: 'https://www.zohoapis.eu/crm/v8/org' })).toEqual({ Authorization: 'Zoho-oauthtoken t' }); + for (const url of ['https://evil.example/x', 'https://www.zohoapis.eu.evil.io/x', 'http://www.zohoapis.eu/crm/v8/org', 'https://www.zohoapis.com/crm/v8/org']) { + expect(() => customApiAuthHeaders({ auth: eu, url })).toThrow(/only to https:\/\/www\.zohoapis\.eu/); + } + }); + + it('falls back to the documented API domain for the location when api_domain is missing', () => { + expect(getApiDomain({ data: { api_domain: 'https://www.zohoapis.eu/' }, props: { location: 'zoho.com' } })).toBe('https://www.zohoapis.eu'); + expect(getApiDomain({ data: {}, props: { location: 'zoho.com' } })).toBe('https://www.zohoapis.com'); + expect(getApiDomain({ data: {}, props: { location: 'zohocloud.ca' } })).toBe('https://www.zohoapis.ca'); + expect(() => getApiDomain({ data: {}, props: { location: 'zoho.evil' } })).toThrow(/Reconnect/); + }); + + it('registers every action and trigger', () => { + expect(Object.keys(zohoCrm.actions())).toEqual(expect.arrayContaining(['read-file', 'create_record', 'update_record', 'get_record', 'upsert_record', 'delete_record', 'convert_lead', 'add_note', 'add_tags_to_record', 'upload_attachment', 'custom_api_call'])); + expect(Object.keys(zohoCrm.triggers()).sort()).toEqual(['new_contact', 'new_record', 'updated_record']); + }); +}); + +describe('list inputs', () => { + it('parses a JSON array string, a JSON string inside an array, and plain comma text', () => { + expect(stringList('["a, b", "c"]')).toEqual(['a, b', 'c']); + expect(stringList(['["x"]', 'y'])).toEqual(['x', 'y']); + expect(stringList('a, b')).toEqual(['a', 'b']); + expect(stringList(['[VIP]'])).toEqual(['[VIP]']); + expect(stringList(undefined)).toEqual([]); + }); +}); + +describe('multipart encoder', () => { + it('builds a file part and a text part', () => { + const { body, contentType } = buildMultipart({ + parts: [{ name: 'file', filename: 'a"b.txt', data: Buffer.from('hi') }, { name: 'title', data: 'T' }], + boundary: 'BOUND', + }); + expect(contentType).toBe('multipart/form-data; boundary=BOUND'); + const text = body.toString(); + expect(text).toContain('Content-Disposition: form-data; name="file"; filename="a_b.txt"\r\nContent-Type: application/octet-stream\r\n\r\nhi\r\n'); + expect(text).toContain('name="title"\r\n\r\nT\r\n'); + expect(text.endsWith('--BOUND--\r\n')).toBe(true); + }); +}); + +describe('tier-2 fixes', () => { + it('default field selection keeps custom fields when a module has more than 50 fields', () => { + const standard: ZohoField[] = Array.from({ length: 60 }, (_, i) => ({ api_name: `Std_${i}` })); + const fields: ZohoField[] = [ + { api_name: 'id' }, + { api_name: 'Latitude' }, + { api_name: 'Locked__s' }, + ...standard, + { api_name: 'AP_Text', custom_field: true }, + { api_name: 'Hidden_Custom', custom_field: true, visible: false }, + ]; + const picked = defaultFieldSelection({ fields }); + expect(picked).toHaveLength(50); + expect(picked.slice(0, 3)).toEqual(['id', 'AP_Text', 'Std_0']); + expect(picked).not.toContain('Latitude'); + expect(picked).not.toContain('Hidden_Custom'); + }); + + it('offers Owner in the record form although Zoho marks it field_read_only', () => { + const props = buildFieldProps({ fields: [{ api_name: 'Owner', data_type: 'ownerlookup', field_read_only: true }, { api_name: 'Locked', data_type: 'text', field_read_only: true }], mode: 'create' }); + expect(Object.keys(props)).toEqual(['Owner']); + expect(isApiReadOnly({ api_name: 'Owner', data_type: 'ownerlookup', read_only: true })).toBe(true); + }); + + it('explains a delete of a missing or already-deleted record', async () => { + sendRequest.mockRejectedValueOnce(httpError({ status: 400, body: { data: [{ code: 'INVALID_DATA', status: 'error', message: 'record not deleted', details: {} }] } })); + await expect(deleteRecord({ auth: AUTH, module: 'Leads', id: '1' })).rejects.toThrow(/could not delete Leads record 1; it does not exist or was already deleted/); + }); + + it('hints at WorkDrive when file attachments are not enabled', () => { + const e = toZohoError(httpError({ status: 400, body: { code: 'INTEGRATION_NOT_ENABLED', message: 'This action requires WorkDrive integration.' } })); + expect(e.message).toMatch(/INTEGRATION_NOT_ENABLED.*WorkDrive.*attach a link/); + }); + + it('flattens link attachments with type and link_url', () => { + expect(flattenAttachment({ id: '1', File_Name: 'Site', Size: '0', $type: 'Link URL', $link_url: 'https://x.test/', $file_id: null })).toMatchObject({ + size: 0, + type: 'Link URL', + link_url: 'https://x.test/', + file_id: null, + }); + }); +}); + +function idOf(record: unknown): string { + if (typeof record === 'object' && record !== null && 'id' in record && typeof record.id === 'string') { + return record.id; + } + throw new Error('record without id'); +} diff --git a/packages/pieces/community/zoho-crm/vitest.config.ts b/packages/pieces/community/zoho-crm/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/community/zoho-crm/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + 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'), + }, + }, +}) diff --git a/packages/pieces/core/xml/package.json b/packages/pieces/core/xml/package.json index 6f9a28325fb2..db2c711f3eb5 100644 --- a/packages/pieces/core/xml/package.json +++ b/packages/pieces/core/xml/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-xml", - "version": "0.1.20", + "version": "0.2.1", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/xml/src/i18n/translation.json b/packages/pieces/core/xml/src/i18n/translation.json index 7425d31dcf85..87a4f5ece1b9 100644 --- a/packages/pieces/core/xml/src/i18n/translation.json +++ b/packages/pieces/core/xml/src/i18n/translation.json @@ -1,9 +1,35 @@ { "Extensible Markup Language for storing and transporting data": "Extensible Markup Language for storing and transporting data", "Convert JSON to XML": "Convert JSON to XML", + "Convert XML to JSON": "Convert XML to JSON", + "Create XML": "Create XML", + "Build an XML document from JSON, with attributes, repeated elements, CDATA and an optional XML declaration": "Build an XML document from JSON, with attributes, repeated elements, CDATA and an optional XML declaration", "JSON": "JSON", "Attribute field": "Attribute field", "Header": "Header", + "XML": "XML", + "Ignore Attributes": "Ignore Attributes", + "Root Element": "Root Element", + "List Item Element": "List Item Element", + "Pretty Print": "Pretty Print", + "Include XML Declaration": "Include XML Declaration", + "Self-Close Empty Elements": "Self-Close Empty Elements", + "Attribute Prefix": "Attribute Prefix", + "Text Key": "Text Key", + "CDATA Key": "CDATA Key", "Field to add your tag's attributes": "Field to add your tag's attributes", - "Add XML header": "Add XML header" + "Add XML header": "Add XML header", + "The XML string to convert": "The XML string to convert", + "Ignore XML tag attributes during parsing. When unchecked, attributes are included in the output with a \"@_\" prefix (e.g. id=\"42\" becomes {\"@_id\": \"42\"}).": "Ignore XML tag attributes during parsing. When unchecked, attributes are included in the output with a \"@_\" prefix (e.g. id=\"42\" becomes {\"@_id\": \"42\"}).", + "Each key becomes an element and each list item becomes a repeated element. Keys starting with \"@_\" become attributes, \"#text\" sets the text of an element that has attributes, and \"#cdata\" writes a CDATA section. Example: {\"book\": {\"@_id\": \"1\", \"title\": \"Dune\", \"tag\": [\"scifi\", \"classic\"]}}. The output of Convert XML to JSON can be used as-is.": "Each key becomes an element and each list item becomes a repeated element. Keys starting with \"@_\" become attributes, \"#text\" sets the text of an element that has attributes, and \"#cdata\" writes a CDATA section. Example: {\"book\": {\"@_id\": \"1\", \"title\": \"Dune\", \"tag\": [\"scifi\", \"classic\"]}}. The output of Convert XML to JSON can be used as-is.", + "Wraps the output in one element with this name. Leave empty if your JSON already has a single top-level key. If the JSON is a list or has several top-level keys and this is empty, \"root\" is used.": "Wraps the output in one element with this name. Leave empty if your JSON already has a single top-level key. If the JSON is a list or has several top-level keys and this is empty, \"root\" is used.", + "Element name for each item when the JSON itself is a list. Defaults to \"item\".": "Element name for each item when the JSON itself is a list. Defaults to \"item\".", + "Put each element on its own line; text content is never changed.": "Put each element on its own line; text content is never changed.", + "Start the output with . To use a different declaration, add a \"?xml\" key to your JSON instead, e.g. {\"?xml\": {\"@_version\": \"1.0\", \"@_standalone\": \"yes\"}}.": "Start the output with . To use a different declaration, add a \"?xml\" key to your JSON instead, e.g. {\"?xml\": {\"@_version\": \"1.0\", \"@_standalone\": \"yes\"}}.", + "Write empty values as instead of .": "Write empty values as instead of .", + "Keys starting with this prefix become attributes. Defaults to \"@_\", the prefix Convert XML to JSON uses.": "Keys starting with this prefix become attributes. Defaults to \"@_\", the prefix Convert XML to JSON uses.", + "Key that holds the text of an element that also has attributes. Defaults to \"#text\".": "Key that holds the text of an element that also has attributes. Defaults to \"#text\".", + "Key whose value is written as a CDATA section, so it is not escaped. Defaults to \"#cdata\".": "Key whose value is written as a CDATA section, so it is not escaped. Defaults to \"#cdata\".", + "Keep Leading Zeros": "Keep Leading Zeros", + "Keep numbers that start with a zero as text so values like ZIP codes, IDs and account numbers are not changed (e.g. \"02134\" stays \"02134\" instead of becoming 2134). Other numbers are still converted to numbers.": "Keep numbers that start with a zero as text so values like ZIP codes, IDs and account numbers are not changed (e.g. \"02134\" stays \"02134\" instead of becoming 2134). Other numbers are still converted to numbers." } \ No newline at end of file diff --git a/packages/pieces/core/xml/src/index.ts b/packages/pieces/core/xml/src/index.ts index 3de4ec090cdb..bf3f076cab36 100644 --- a/packages/pieces/core/xml/src/index.ts +++ b/packages/pieces/core/xml/src/index.ts @@ -2,6 +2,7 @@ import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { convertJsonToXml } from './lib/actions/convert-json-to-xml'; import { convertXmlToJson } from './lib/actions/convert-xml-to-json'; +import { createXml } from './lib/actions/create-xml'; export const xml = createPiece({ displayName: 'XML', @@ -12,6 +13,6 @@ export const xml = createPiece({ categories: [PieceCategory.CORE], auth: PieceAuth.None(), authors: ["Willianwg","kishanprmr","AbdulTheActivePiecer","khaledmashaly","abuaboud"], - actions: [convertJsonToXml, convertXmlToJson], + actions: [convertJsonToXml, convertXmlToJson, createXml], triggers: [], }); diff --git a/packages/pieces/core/xml/src/lib/actions/convert-json-to-xml.ts b/packages/pieces/core/xml/src/lib/actions/convert-json-to-xml.ts index 0fb030831eea..9800771af547 100644 --- a/packages/pieces/core/xml/src/lib/actions/convert-json-to-xml.ts +++ b/packages/pieces/core/xml/src/lib/actions/convert-json-to-xml.ts @@ -8,7 +8,7 @@ export const convertJsonToXml = createAction({ displayName: 'Convert JSON to XML', description: 'Convert JSON to XML', aiMetadata: { - description: 'Serializes a JSON object or array into an XML string, optionally prepending an XML declaration header and rendering the values under one designated key as tag attributes instead of child elements. Use it when a downstream system expects XML (SOAP endpoint, legacy API, RSS/sitemap body); for the opposite direction use Convert XML to JSON. Requires valid JSON input, and attributes are only emitted for the nested key named by the attribute field (defaults to "attr"); deterministic and idempotent.', + description: 'Serializes a JSON object or array into an XML string, optionally prepending an XML declaration header and rendering the values under one designated key as tag attributes instead of child elements. Prefer Create XML for new work: this action merges list items into one element and does not read the "@_" attribute keys or "#text" that Convert XML to JSON returns. For the opposite direction use Convert XML to JSON. Requires valid JSON input, and attributes are only emitted for the nested key named by the attribute field (defaults to "attr"); deterministic and idempotent.', idempotent: true, }, props: { diff --git a/packages/pieces/core/xml/src/lib/actions/convert-xml-to-json.ts b/packages/pieces/core/xml/src/lib/actions/convert-xml-to-json.ts index ffc2ec09c418..5257fbc44a59 100644 --- a/packages/pieces/core/xml/src/lib/actions/convert-xml-to-json.ts +++ b/packages/pieces/core/xml/src/lib/actions/convert-xml-to-json.ts @@ -8,7 +8,7 @@ export const convertXmlToJson = createAction({ displayName: 'Convert XML to JSON', description: 'Convert XML to JSON', aiMetadata: { - description: 'Parses an XML string into a JSON structure, in either of two modes: keep tag attributes (surfaced as "@_"-prefixed keys) or ignore attributes and keep element text only. Use it to make an XML payload from an HTTP response, webhook body, or RSS/SOAP feed addressable by later steps; for the opposite direction use Convert JSON to XML. Requires a well-formed XML string passed as text, and the XML declaration is always dropped; read-only and idempotent.', + description: 'Parses an XML string into a JSON structure, in either of two modes: keep tag attributes (surfaced as "@_"-prefixed keys) or ignore attributes and keep element text only. Numeric-looking text is converted to numbers unless Keep Leading Zeros is on, in which case zero-prefixed values such as ZIP codes and IDs stay strings. Use it to make an XML payload from an HTTP response, webhook body, or RSS/SOAP feed addressable by later steps; for the opposite direction use Create XML, which accepts this output as-is. Requires a well-formed XML string passed as text, and the XML declaration is always dropped; read-only and idempotent.', idempotent: true, }, props: { @@ -23,10 +23,20 @@ export const convertXmlToJson = createAction({ required: false, defaultValue: false, }), + keepLeadingZeros: Property.Checkbox({ + displayName: 'Keep Leading Zeros', + description: 'Keep numbers that start with a zero as text so values like ZIP codes, IDs and account numbers are not changed (e.g. "02134" stays "02134" instead of becoming 2134). Other numbers are still converted to numbers.', + required: false, + defaultValue: true, + }), }, async run(context) { - const { xml, ignoreAttributes } = context.propsValue; - const parser = new XMLParser({ ignoreAttributes: ignoreAttributes ?? false, ignoreDeclaration: true }); + const { xml, ignoreAttributes, keepLeadingZeros } = context.propsValue; + const parser = new XMLParser({ + ignoreAttributes: ignoreAttributes ?? false, + ignoreDeclaration: true, + ...((keepLeadingZeros ?? false) ? { numberParseOptions: { hex: true, leadingZeros: false, eNotation: true } } : {}), + }); return parser.parse(xml); }, }); diff --git a/packages/pieces/core/xml/src/lib/actions/create-xml.ts b/packages/pieces/core/xml/src/lib/actions/create-xml.ts new file mode 100644 index 000000000000..ce5a69278639 --- /dev/null +++ b/packages/pieces/core/xml/src/lib/actions/create-xml.ts @@ -0,0 +1,327 @@ +import { createAction, OutputSchema, Property, tryCatch } from '@activepieces/pieces-framework'; +import { XMLBuilder, XMLValidator } from 'fast-xml-parser'; + +export const createXml = createAction({ + audience: 'both', + name: 'create-xml', + classification: 'READ', + displayName: 'Create XML', + description: 'Build an XML document from JSON, with attributes, repeated elements, CDATA and an optional XML declaration', + aiMetadata: { + description: 'Builds a well-formed XML string from a JSON object or array: each key becomes an element, each array item becomes a repeated element with the same name, keys prefixed "@_" become attributes of their parent element (including namespace declarations such as "@_xmlns:soap"), "#text" sets the text of an element that also has attributes, and "#cdata" writes a CDATA section. This is the same JSON shape that Convert XML to JSON returns, so the two actions round-trip. Use it to produce a SOAP envelope, legacy API payload, RSS feed, sitemap or file body; prefer it over Convert JSON to XML, which merges list items into one element. A top-level array or several top-level keys are wrapped in one root element ("root" unless Root Element is set). Fails with the offending key and line number when a key is not a valid XML name, and fails when the JSON is nested more than 100 levels deep. Deterministic and idempotent.', + idempotent: true, + }, + outputSchema: createXmlOutputSchema(), + props: { + json: Property.Json({ + displayName: 'JSON', + description: 'Each key becomes an element and each list item becomes a repeated element. Keys starting with "@_" become attributes, "#text" sets the text of an element that has attributes, and "#cdata" writes a CDATA section. Example: {"book": {"@_id": "1", "title": "Dune", "tag": ["scifi", "classic"]}}. The output of Convert XML to JSON can be used as-is.', + required: true, + }), + rootElement: Property.ShortText({ + displayName: 'Root Element', + description: 'Wraps the output in one element with this name. Leave empty if your JSON already has a single top-level key. If the JSON is a list or has several top-level keys and this is empty, "root" is used.', + required: false, + }), + listItemElement: Property.ShortText({ + displayName: 'List Item Element', + description: 'Element name for each item when the JSON itself is a list. Defaults to "item".', + required: false, + }), + prettyPrint: Property.Checkbox({ + displayName: 'Pretty Print', + description: 'Put each element on its own line; text content is never changed.', + required: false, + defaultValue: false, + }), + includeDeclaration: Property.Checkbox({ + displayName: 'Include XML Declaration', + description: 'Start the output with . To use a different declaration, add a "?xml" key to your JSON instead, e.g. {"?xml": {"@_version": "1.0", "@_standalone": "yes"}}.', + required: false, + defaultValue: false, + }), + selfCloseEmptyElements: Property.Checkbox({ + displayName: 'Self-Close Empty Elements', + description: 'Write empty values as instead of .', + required: false, + defaultValue: false, + }), + attributePrefix: Property.ShortText({ + displayName: 'Attribute Prefix', + description: 'Keys starting with this prefix become attributes. Defaults to "@_", the prefix Convert XML to JSON uses.', + required: false, + }), + textKey: Property.ShortText({ + displayName: 'Text Key', + description: 'Key that holds the text of an element that also has attributes. Defaults to "#text".', + required: false, + }), + cdataKey: Property.ShortText({ + displayName: 'CDATA Key', + description: 'Key whose value is written as a CDATA section, so it is not escaped. Defaults to "#cdata".', + required: false, + }), + }, + async run(context) { + const { json, rootElement, listItemElement, prettyPrint, includeDeclaration, selfCloseEmptyElements } = context.propsValue; + const keys: SpecialKeys = { + attributePrefix: context.propsValue.attributePrefix || DEFAULT_ATTRIBUTE_PREFIX, + textKey: context.propsValue.textKey || DEFAULT_TEXT_KEY, + cdataKey: context.propsValue.cdataKey || DEFAULT_CDATA_KEY, + }; + const format = { keys, selfClose: selfCloseEmptyElements ?? false }; + const document = toDocument({ + json, + rootElement: rootElement?.trim() || undefined, + listItemElement: listItemElement?.trim() || DEFAULT_LIST_ITEM_ELEMENT, + keys, + }); + const { data: built, error: buildError } = await tryCatch(async () => buildDocument({ document, format, pretty: prettyPrint ?? false })); + if (buildError) { + throw new InvalidXmlError( + buildError.message === MAX_DEPTH_MESSAGE + ? `The JSON is nested more than ${MAX_DEPTH} levels deep, which is the most Create XML and Convert XML to JSON accept. Flatten the data or split it into smaller documents.` + : `The JSON could not be turned into XML: ${buildError.message}`, + ); + } + const xml = built.trim(); + const validation = XMLValidator.validate(xml); + if (validation !== true) { + const problem = findInvalidName({ value: document, path: '', keys }); + throw new InvalidXmlError( + `The JSON does not produce valid XML: ${problem ? `${problem} ` : ''}${validation.err.msg} (line ${validation.err.line}). Element and attribute names must start with a letter or "_" and can only contain letters, digits, "-", "_", "." and ":".`, + ); + } + if (!includeDeclaration || XML_DECLARATION_START.test(xml)) { + return xml; + } + return `${XML_DECLARATION}${prettyPrint ? '\n' : ''}${xml}`; + }, +}); + +function buildDocument({ document, format, pretty }: { document: Record; format: BuildFormat; pretty: boolean }): string { + if (!pretty) { + return String(createBuilder({ format, pretty: false, maxDepth: MAX_DEPTH }).build(document)); + } + const token = inlineToken({ document }); + const { value, inlined } = inlineMixedChildren({ node: document, format, token, depth: 1, offset: 0 }); + const xml = String(createBuilder({ format, pretty: true, maxDepth: MAX_DEPTH }).build(value)); + return restoreInlined({ xml, token, inlined }); +} + +function restoreInlined({ xml, token, inlined }: { xml: string; token: string; inlined: string[] }): string { + const [head, ...parts] = xml.split(token); + return parts.reduce((result, part) => { + const match = INLINED_INDEX.exec(part); + const content = match ? inlined[Number(match[1])] : undefined; + return match && content !== undefined ? `${result}${content}${part.slice(match[0].length)}` : `${result}${token}${part}`; + }, head); +} + +function createBuilder({ format, pretty, maxDepth }: { format: BuildFormat; pretty: boolean; maxDepth: number }): XMLBuilder { + return new XMLBuilder({ + ignoreAttributes: false, + attributeNamePrefix: format.keys.attributePrefix, + textNodeName: format.keys.textKey, + cdataPropName: format.keys.cdataKey, + format: pretty, + indentBy: ' ', + suppressEmptyNode: format.selfClose, + suppressBooleanAttributes: false, + processEntities: true, + maxNestedTags: maxDepth, + }); +} + +function inlineMixedChildren({ node, format, token, depth, offset }: InlineParams): InlineResult { + const entries: [string, unknown][] = []; + const inlined: string[] = []; + for (const [key, child] of Object.entries(node)) { + if (!isElementKey({ key, keys: format.keys }) || key.startsWith('?')) { + entries.push([key, child]); + continue; + } + const inner = inlineElement({ name: key, value: child, format, token, depth, offset: offset + inlined.length }); + entries.push([key, inner.value]); + appendAll({ target: inlined, items: inner.inlined }); + } + return { value: Object.fromEntries(entries), inlined }; +} + +function inlineElement({ name, value, format, token, depth, offset }: InlineElementParams): { value: unknown; inlined: string[] } { + if (Array.isArray(value)) { + const items: unknown[] = []; + const inlined: string[] = []; + for (const item of value) { + const inner = inlineElement({ name, value: item, format, token, depth, offset: offset + inlined.length }); + items.push(inner.value); + appendAll({ target: inlined, items: inner.inlined }); + } + return { value: items, inlined }; + } + if (!isRecord(value) || depth > MAX_DEPTH) { + return { value, inlined: [] }; + } + if (!isMixedContent({ value, keys: format.keys })) { + return inlineMixedChildren({ node: value, format, token, depth: depth + 1, offset }); + } + const attributes = Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith(format.keys.attributePrefix))); + const content = Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith(format.keys.attributePrefix))); + const element = String(createBuilder({ format, pretty: false, maxDepth: MAX_DEPTH - depth + 1 }).build({ [name]: content })); + const open = `<${name}>`; + const close = ``; + if (!element.startsWith(open) || !element.endsWith(close)) { + return { value, inlined: [] }; + } + return { value: { ...attributes, [format.keys.textKey]: `${token}${offset}_` }, inlined: [element.slice(open.length, element.length - close.length)] }; +} + +function appendAll({ target, items }: { target: string[]; items: string[] }): void { + for (const item of items) { + target.push(item); + } +} + +function isMixedContent({ value, keys }: { value: Record; keys: SpecialKeys }): boolean { + if (keys.cdataKey in value) { + return true; + } + const text = value[keys.textKey]; + const hasText = text !== undefined && text !== null && text !== ''; + return hasText && Object.keys(value).some((key) => isElementKey({ key, keys })); +} + +function inlineToken({ document }: { document: Record }): string { + const serialized = JSON.stringify(document); + const longestRun = Array.from(serialized.matchAll(INLINE_TOKEN_PATTERN), (match) => match[1].length).reduce((longest, run) => Math.max(longest, run), -1); + return `${INLINE_TOKEN}${'x'.repeat(longestRun + 1)}`; +} + +function toDocument({ json, rootElement, listItemElement, keys }: ToDocumentParams): Record { + if (Array.isArray(json)) { + return { [rootElement ?? DEFAULT_ROOT_ELEMENT]: { [listItemElement]: json } }; + } + if (!isRecord(json)) { + return { [rootElement ?? DEFAULT_ROOT_ELEMENT]: json ?? '' }; + } + const entries = Object.entries(json).sort(([a], [b]) => Number(b === XML_DECLARATION_KEY) - Number(a === XML_DECLARATION_KEY)); + const instructions = entries.filter(([key]) => key.startsWith('?')); + const nodes = entries.filter(([key]) => !key.startsWith('?')); + const hasSingleRoot = nodes.length === 1 && !Array.isArray(nodes[0][1]) && isElementKey({ key: nodes[0][0], keys }); + if (!rootElement && hasSingleRoot) { + return Object.fromEntries([...instructions, ...nodes]); + } + return { + ...Object.fromEntries(instructions), + [rootElement ?? DEFAULT_ROOT_ELEMENT]: Object.fromEntries(nodes), + }; +} + +function isElementKey({ key, keys }: { key: string; keys: SpecialKeys }): boolean { + return !key.startsWith(keys.attributePrefix) && key !== keys.textKey && key !== keys.cdataKey; +} + +function findInvalidName({ value, path, keys }: { value: unknown; path: string; keys: SpecialKeys }): string | undefined { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + const itemPath = `${path}[${index}]`; + if (Array.isArray(item)) { + return `The list at ${itemPath} sits directly inside another list, so it has no element name; wrap it in an object such as {"row": [...]}.`; + } + const found = findInvalidName({ value: item, path: itemPath, keys }); + if (found) { + return found; + } + } + return undefined; + } + if (!isRecord(value)) { + return undefined; + } + for (const [key, child] of Object.entries(value)) { + const childPath = path ? `${path}.${key}` : key; + const isAttribute = key.startsWith(keys.attributePrefix); + const name = isAttribute ? key.slice(keys.attributePrefix.length) : key.replace(/^\?/, ''); + if (key !== keys.textKey && key !== keys.cdataKey && !XML_NAME.test(name)) { + return `"${name}" at ${childPath} is not a valid XML ${isAttribute ? 'attribute' : 'element'} name.`; + } + const found = isAttribute ? undefined : findInvalidName({ value: child, path: childPath, keys }); + if (found) { + return found; + } + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function createXmlOutputSchema(): OutputSchema { + return { + fields: [{ key: 'xml', label: 'XML', value: '', description: 'The XML document built from the JSON input.' }], + }; +} + +class InvalidXmlError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidXmlError'; + } +} + +const DEFAULT_ATTRIBUTE_PREFIX = '@_'; +const DEFAULT_TEXT_KEY = '#text'; +const DEFAULT_CDATA_KEY = '#cdata'; +const DEFAULT_ROOT_ELEMENT = 'root'; +const DEFAULT_LIST_ITEM_ELEMENT = 'item'; +const XML_DECLARATION = ''; +const MAX_DEPTH = 100; +const XML_DECLARATION_KEY = '?xml'; +const XML_DECLARATION_START = /^<\?xml[\s?]/; +const INLINE_TOKEN = 'apinlinexml'; +const INLINE_TOKEN_PATTERN = new RegExp(`${INLINE_TOKEN}(x*)`, 'g'); +const INLINED_INDEX = /^(\d+)_/; +const MAX_DEPTH_MESSAGE = 'Maximum nested tags exceeded'; +const NAME_START_CHARS = ':A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD'; +const XML_NAME = new RegExp(`^[${NAME_START_CHARS}][${NAME_START_CHARS}\\-.\\d\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*$`); + +type SpecialKeys = { + attributePrefix: string; + textKey: string; + cdataKey: string; +}; + +type BuildFormat = { + keys: SpecialKeys; + selfClose: boolean; +}; + +type InlineResult = { + value: Record; + inlined: string[]; +}; + +type InlineParams = { + node: Record; + format: BuildFormat; + token: string; + depth: number; + offset: number; +}; + +type InlineElementParams = { + name: string; + value: unknown; + format: BuildFormat; + token: string; + depth: number; + offset: number; +}; + +type ToDocumentParams = { + json: unknown; + rootElement: string | undefined; + listItemElement: string; + keys: SpecialKeys; +}; diff --git a/packages/pieces/core/xml/test/convert-xml-to-json.test.ts b/packages/pieces/core/xml/test/convert-xml-to-json.test.ts index 05be25c0e757..864014837655 100644 --- a/packages/pieces/core/xml/test/convert-xml-to-json.test.ts +++ b/packages/pieces/core/xml/test/convert-xml-to-json.test.ts @@ -64,3 +64,71 @@ describe('convertXmlToJson', () => { expect(root['item']).toEqual([1, 2, 3]); }); }); + +describe('convertXmlToJson keepLeadingZeros', () => { + test('keeps leading zeros as strings when enabled', async () => { + const ctx = createMockActionContext({ + propsValue: { + xml: '021340012342', + ignoreAttributes: false, + keepLeadingZeros: true, + }, + }); + const result = await convertXmlToJson.run(ctx); + expect(result).toEqual({ root: { zip: '02134', id: '00123', count: 42 } }); + }); + + test('keeps leading zeros in nested elements and arrays when enabled', async () => { + const ctx = createMockActionContext({ + propsValue: { + xml: '000987
02134
12345
00501
', + ignoreAttributes: false, + keepLeadingZeros: true, + }, + }); + const result = await convertXmlToJson.run(ctx); + expect(result).toEqual({ + customers: { + customer: [ + { account: '000987', address: { zip: '02134' } }, + { account: 12345, address: { zip: '00501' } }, + ], + }, + }); + }); + + test('keeps parsing zero, decimals and exponents as numbers when enabled', async () => { + const ctx = createMockActionContext({ + propsValue: { + xml: '00.51e5007.5', + ignoreAttributes: false, + keepLeadingZeros: true, + }, + }); + const result = await convertXmlToJson.run(ctx); + expect(result).toEqual({ root: { a: 0, b: 0.5, c: 100000, d: '007.5' } }); + }); + + test('strips leading zeros when disabled', async () => { + const ctx = createMockActionContext({ + propsValue: { + xml: '021340012342', + ignoreAttributes: false, + keepLeadingZeros: false, + }, + }); + const result = await convertXmlToJson.run(ctx); + expect(result).toEqual({ root: { zip: 2134, id: 123, count: 42 } }); + }); + + test('keeps the previous numeric output for existing steps without the option', async () => { + const ctx = createMockActionContext({ + propsValue: { + xml: '021340078', + ignoreAttributes: false, + }, + }); + const result = await convertXmlToJson.run(ctx); + expect(result).toEqual({ root: { zip: 2134, item: [7, 8] } }); + }); +}); diff --git a/packages/pieces/core/xml/test/create-xml.test.ts b/packages/pieces/core/xml/test/create-xml.test.ts new file mode 100644 index 000000000000..824dabfbb4db --- /dev/null +++ b/packages/pieces/core/xml/test/create-xml.test.ts @@ -0,0 +1,568 @@ +/// + +import { createXml } from '../src/lib/actions/create-xml'; +import { convertXmlToJson } from '../src/lib/actions/convert-xml-to-json'; +import { convertJsonToXml } from '../src/lib/actions/convert-json-to-xml'; +import { createMockActionContext } from '@activepieces/pieces-framework'; + +describe('createXml', () => { + describe('lists', () => { + test('repeats the element for each value in a list', async () => { + expect(await buildXml({ json: { post: { tags: ['a', 'b'] } } })).toBe('ab'); + }); + + test('wraps a single top-level key that holds a list, so {"tags":["a","b"]} gives one tags element per item', async () => { + expect(await buildXml({ json: { tags: ['a', 'b'] } })).toBe('ab'); + expect(await buildXml({ json: { tags: ['a', 'b'] }, rootElement: 'post' })).toBe('ab'); + }); + + test('writes one element per object in a list', async () => { + expect(await buildXml({ json: { root: { row: [{ c: 1 }, { c: 2, d: 'x' }] } } })).toBe( + '12x', + ); + }); + + test('keeps attributes on each repeated element', async () => { + expect(await buildXml({ json: { r: { i: [{ '@_n': '1', '#text': 'a' }, { '@_n': '2', '#text': 'b' }] } } })).toBe( + 'ab', + ); + }); + + test('omits the element for an empty list', async () => { + expect(await buildXml({ json: { r: { items: [], o: {} } } })).toBe(''); + }); + + test('writes null items as empty repeated elements', async () => { + expect(await buildXml({ json: { r: { c: [null, 1] } } })).toBe('1'); + }); + }); + + describe('attributes, text and CDATA', () => { + test('writes @_ keys as attributes of their parent element', async () => { + expect(await buildXml({ json: { book: { '@_id': '1', title: 'T' } } })).toBe('T'); + }); + + test('writes #text next to attributes', async () => { + expect(await buildXml({ json: { price: { '@_currency': 'USD', '#text': 9.5 } } })).toBe('9.5'); + }); + + test('writes boolean and number attributes with a value', async () => { + expect(await buildXml({ json: { input: { '@_checked': true, '@_hidden': false, '@_size': 0 } } })).toBe( + '', + ); + }); + + test('writes namespace declarations and prefixed names', async () => { + expect(await buildXml({ json: soapEnvelope })).toBe( + 'Apples', + ); + }); + + test('writes a default namespace', async () => { + expect(await buildXml({ json: { feed: { '@_xmlns': 'http://www.w3.org/2005/Atom', title: 'x' } } })).toBe( + 'x', + ); + }); + + test('writes #cdata as a CDATA section and splits an embedded ]]>', async () => { + expect(await buildXml({ json: { a: { '#cdata': 'bold & more' } } })).toBe('bold & more]]>'); + expect(await buildXml({ json: { a: { '#cdata': 'x]]>y' } } })).toBe('y]]>'); + }); + + test('supports a custom attribute prefix, text key and CDATA key', async () => { + expect( + await buildXml({ json: { price: { $currency: 'USD', _: 9.5, note: { '!c': '' } } }, attributePrefix: '$', textKey: '_', cdataKey: '!c' }), + ).toBe('9.5]]>'); + }); + + test('treats @_ keys as plain names once a custom prefix is set', async () => { + await expect(buildXml({ json: { a: { '@_id': '1' } }, attributePrefix: '$' })).rejects.toThrow('"@_id" at a.@_id is not a valid XML element name'); + }); + + test('wraps top-level attributes or text in the root element', async () => { + expect(await buildXml({ json: { '@_version': '2', item: 'x' } })).toBe('x'); + expect(await buildXml({ json: { '#text': 'hi' } })).toBe('hi'); + }); + }); + + describe('escaping and unicode', () => { + test('escapes special characters in text', async () => { + expect(await buildXml({ json: { a: `x & y "q" 'r'` } })).toBe('x & y <z> "q" 'r''); + }); + + test('escapes special characters in attribute values', async () => { + expect(await buildXml({ json: { a: { '@_t': `x"y&<>'`, b: 1 } } })).toBe('1'); + }); + + test('does not double-escape existing entities', async () => { + expect(await buildXml({ json: { a: '&' } })).toBe('&amp;'); + }); + + test('keeps unicode and emoji as-is in text, attributes and names', async () => { + expect(await buildXml({ json: { name: { '@_e': '😀', '#text': 'café 日本 😀' } } })).toBe('café 日本 😀'); + expect(await buildXml({ json: { 'café': { 日本: 1 } } })).toBe('<日本>1'); + }); + }); + + describe('root element and top-level shapes', () => { + test('keeps a single top-level key as the root', async () => { + expect(await buildXml({ json: { person: { name: 'Alice', address: { city: 'LA' } } } })).toBe( + 'Alice
LA
', + ); + }); + + test('wraps a single top-level key when Root Element is set', async () => { + expect(await buildXml({ json: { person: { name: 'Alice' } }, rootElement: 'data' })).toBe('Alice'); + }); + + test('wraps several top-level keys in one root element', async () => { + expect(await buildXml({ json: { a: 1, b: 2 } })).toBe('12'); + expect(await buildXml({ json: { a: 1, b: 2 }, rootElement: 'data' })).toBe('12'); + }); + + test('trims Root Element and List Item Element and falls back when blank', async () => { + expect(await buildXml({ json: [1], rootElement: ' rows ', listItemElement: ' ' })).toBe('1'); + }); + + test('wraps a top-level list in root and item elements', async () => { + expect(await buildXml({ json: [{ a: 1 }, { b: 2 }] })).toBe('12'); + }); + + test('uses a custom list item element for a top-level list', async () => { + expect(await buildXml({ json: [{ a: 1 }, { a: 2 }], rootElement: 'rows', listItemElement: 'row' })).toBe( + '12', + ); + expect(await buildXml({ json: ['x', 'y'], listItemElement: 'v' })).toBe('xy'); + }); + + test('writes an empty root for an empty object or list', async () => { + expect(await buildXml({ json: {} })).toBe(''); + expect(await buildXml({ json: [] })).toBe(''); + expect(await buildXml({ json: {}, rootElement: 'data', selfCloseEmptyElements: true })).toBe(''); + }); + + test('wraps a top-level primitive in the root element', async () => { + expect(await buildXml({ json: 'abc' })).toBe('abc'); + expect(await buildXml({ json: 0 })).toBe('0'); + expect(await buildXml({ json: false })).toBe('false'); + expect(await buildXml({ json: '' })).toBe(''); + }); + + test('writes an empty root for null or undefined', async () => { + expect(await buildXml({ json: null })).toBe(''); + expect(await buildXml({ json: undefined })).toBe(''); + expect(await buildXml({ json: null, rootElement: 'data' })).toBe(''); + }); + }); + + describe('declaration and formatting', () => { + test('adds the XML declaration when asked', async () => { + expect(await buildXml({ json: { a: 1 }, includeDeclaration: true })).toBe('1'); + }); + + test('leaves the declaration out by default', async () => { + expect(await buildXml({ json: { a: 1 } })).toBe('1'); + }); + + test('uses a ?xml key instead of the default declaration and does not duplicate it', async () => { + const json = { '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8', '@_standalone': 'yes' }, a: 1 }; + const expected = '1'; + expect(await buildXml({ json, includeDeclaration: true })).toBe(expected); + expect(await buildXml({ json, includeDeclaration: false })).toBe(expected); + }); + + test('keeps a ?xml key outside the root element it wraps around', async () => { + expect(await buildXml({ json: { '?xml': { '@_version': '1.0' }, a: 1, b: 2 }, includeDeclaration: true })).toBe( + '12', + ); + expect(await buildXml({ json: { '?xml': { '@_version': '1.0' }, a: [1, 2] } })).toBe('12'); + }); + + test('pretty prints with two-space indentation', async () => { + expect(await buildXml({ json: { r: { a: 1, b: { c: [1, 2] } } }, prettyPrint: true, includeDeclaration: true })).toBe( + '\n\n 1\n \n 1\n 2\n \n', + ); + }); + + test('pretty prints a ?xml declaration on its own line', async () => { + expect(await buildXml({ json: { '?xml': { '@_version': '1.0' }, a: 1 }, prettyPrint: true, includeDeclaration: true })).toBe( + '\n1', + ); + }); + + test('keeps newlines inside text when pretty printing', async () => { + expect(await buildXml({ json: { r: { a: 'x\ny' } }, prettyPrint: true })).toBe('\n x\ny\n'); + }); + + test('adds the declaration when the document starts with another processing instruction', async () => { + const json = { '?xml-stylesheet': { '@_type': 'text/xsl', '@_href': 's.xsl' }, rss: { a: 1 } }; + expect(await buildXml({ json, includeDeclaration: true })).toBe( + '1', + ); + }); + + test('writes a ?xml key first even when another instruction comes before it', async () => { + const json = { '?pi': { '@_a': '1' }, '?xml': { '@_version': '1.0' }, r: { a: 1 } }; + expect(await buildXml({ json })).toBe('1'); + expect(await buildXml({ json, includeDeclaration: true })).toBe('1'); + }); + + test('writes instructions before the root element even when the root key comes first', async () => { + const json = { r: { a: 1 }, '?pi': { '@_a': '1' }, '?xml': { '@_version': '1.0' } }; + expect(await buildXml({ json })).toBe('1'); + }); + + test('keeps an element that mixes text and child elements on one line when pretty printing', async () => { + const json = { doc: { title: 'T', p: { '@_x': '1', '#text': 'Hello ', b: 'world' } } }; + expect(await buildXml({ json, prettyPrint: true })).toBe('\n T\n

Hello world

\n
'); + expect(await parseXml({ xml: await buildXml({ json, prettyPrint: true }) })).toEqual(await parseXml({ xml: await buildXml({ json }) })); + }); + + test('keeps CDATA inside its element when pretty printing', async () => { + const json = { r: { a: 1, note: { '#cdata': 'raw html' } } }; + expect(await buildXml({ json, prettyPrint: true })).toBe('\n 1\n html]]>\n'); + }); + + test('keeps each mixed item of a list on one line and escapes its text', async () => { + const json = { r: { p: [{ '#text': 'a & b ', i: 'x' }, { '#text': 'c', i: 'y' }] } }; + expect(await buildXml({ json, prettyPrint: true })).toBe('\n

a & b x

\n

cy

\n
'); + }); + + test('leaves user text that looks like the internal placeholder alone', async () => { + const json = { r: { a: 'apinlinexml0_', p: { '#text': 'mixed ', b: 'bold' } } }; + expect(await buildXml({ json, prettyPrint: true })).toBe('\n apinlinexml0_\n

mixed bold

\n
'); + }); + + test('never mistakes user text for the internal placeholder, whatever it contains', async () => { + const lookalikes = Array.from({ length: 12 }, (_, index) => `apinlinexml${'x'.repeat(index)}0_`); + const json = { r: { a: lookalikes, p: { '#text': 'mixed ', b: 'bold' } } }; + const expected = ['', ...lookalikes.map((text) => ` ${text}`), '

mixed bold

', '
'].join('\n'); + expect(await buildXml({ json, prettyPrint: true })).toBe(expected); + expect(await buildXml({ json: { apinlinexml0_: { '#text': 'x ', b: 'y' } }, prettyPrint: true })).toBe('x y'); + }); + + test('keeps a self-closed empty CDATA element self-closed when pretty printing', async () => { + expect(await buildXml({ json: { r: { a: 1, n: { '#cdata': null } } }, prettyPrint: true, selfCloseEmptyElements: true })).toBe('\n 1\n \n'); + }); + + test('Pretty Print only adds line breaks and indentation between tags', async () => { + const random = seededRandom({ seed: 7 }); + for (let index = 0; index < 500; index++) { + const json = { r: randomNode({ random, depth: 0 }) }; + for (const selfCloseEmptyElements of [false, true]) { + const compact = await buildXml({ json, selfCloseEmptyElements }).catch(() => undefined); + if (compact === undefined) { + continue; + } + const pretty = await buildXml({ json, prettyPrint: true, selfCloseEmptyElements }); + expect(pretty.replace(/>\n\s*<')).toBe(compact); + } + } + }); + + test('still enforces the depth limit inside a mixed element', async () => { + await expect(buildXml({ json: { r: { '#text': 't', deep: nest({ depth: 120 }) } }, prettyPrint: true })).rejects.toThrow('100 levels'); + }); + }); + + describe('empty and scalar values', () => { + test('writes null as a self-closed element and empty string as an open-close pair', async () => { + expect(await buildXml({ json: { r: { a: null, c: '' } } })).toBe(''); + }); + + test('skips undefined values', async () => { + expect(await buildXml({ json: { r: { a: 1, b: undefined } } })).toBe('1'); + }); + + test('self-closes every empty element when asked', async () => { + expect(await buildXml({ json: { r: { a: null, c: '', o: {}, x: { '@_id': '1' } } }, selfCloseEmptyElements: true })).toBe( + '', + ); + }); + + test('writes booleans and numbers as text', async () => { + expect(await buildXml({ json: { r: { t: true, f: false, zero: 0, n: 1.5, neg: -3, big: 12345678901234 } } })).toBe( + 'truefalse01.5-312345678901234', + ); + }); + + test('writes nested nulls as empty elements', async () => { + expect(await buildXml({ json: { r: { a: { b: null, c: { d: null } } } } })).toBe(''); + }); + + test('keeps leading-zero strings as written', async () => { + expect(await buildXml({ json: { r: { zip: '02134', id: '007' } } })).toBe('02134007'); + }); + }); + + describe('awkward key names', () => { + test('handles a key named length', async () => { + expect(await buildXml({ json: { a: { length: 3 } } })).toBe('3'); + expect(await buildXml({ json: { length: 3 } })).toBe('3'); + }); + + test('handles __proto__ and constructor keys without touching the prototype', async () => { + expect(await buildXml({ json: JSON.parse('{"r":{"__proto__":{"x":1},"constructor":2}}') })).toBe( + '<__proto__>12', + ); + expect(await buildXml({ json: JSON.parse('{"__proto__":{"polluted":true}}') })).toBe('<__proto__>true'); + expect(Object.prototype).not.toHaveProperty('polluted'); + }); + + test('handles keys named after Object methods', async () => { + expect(await buildXml({ json: { r: { hasOwnProperty: 1, toString: 2, valueOf: 3 } } })).toBe( + '123', + ); + }); + }); + + describe('invalid names', () => { + test('fails with the key, its path and the line on an element name with a space', async () => { + const error = await buildXml({ json: { r: { ok: 1, 'bad key': 1 } } }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(String(error)).toContain('InvalidXmlError'); + expect(String(error)).toContain('does not produce valid XML'); + expect(String(error)).toContain('"bad key" at r.bad key is not a valid XML element name'); + expect(String(error)).toContain('(line 1)'); + }); + + test('reports the line of the bad element when pretty printing', async () => { + await expect(buildXml({ json: { r: { a: 1, b: 2, '1st': 3 } }, prettyPrint: true })).rejects.toThrow('(line 4)'); + }); + + test('fails on an element name that starts with a digit', async () => { + await expect(buildXml({ json: { r: { '1st': 1 } } })).rejects.toThrow(/"1st" at r\.1st is not a valid XML element name\. Tag '1st' is an invalid name\. \(line 1\)/); + }); + + test('fails on an invalid attribute name', async () => { + await expect(buildXml({ json: { r: { '@_bad attr': 1 } } })).rejects.toThrow('"bad attr" at r.@_bad attr is not a valid XML attribute name'); + }); + + test('fails on an empty key, a key with symbols and an invalid root element', async () => { + await expect(buildXml({ json: { r: { '': 1 } } })).rejects.toThrow('is not a valid XML element name'); + await expect(buildXml({ json: { r: { 'a$b': 1 } } })).rejects.toThrow('"a$b" at r.a$b is not a valid XML element name'); + await expect(buildXml({ json: { a: 1 }, rootElement: 'bad root' })).rejects.toThrow('"bad root" at bad root is not a valid XML element name'); + }); + + test('fails on a list directly inside a list', async () => { + await expect(buildXml({ json: { m: { row: [[1, 2], [3]] } } })).rejects.toThrow('The list at m.row[0] sits directly inside another list'); + }); + + test('finds a bad name deep inside a list of objects', async () => { + await expect(buildXml({ json: { r: { row: [{ ok: 1 }, { deep: { 'no good': 1 } }] } } })).rejects.toThrow( + '"no good" at r.row[1].deep.no good is not a valid XML element name', + ); + }); + }); + + describe('size and depth', () => { + test('builds 99 levels of nesting quickly', async () => { + const started = performance.now(); + const xml = await buildXml({ json: nest({ depth: 99 }) }); + expect(performance.now() - started).toBeLessThan(1000); + expect(xml.startsWith('')).toBe(true); + expect(xml.match(//g)).toHaveLength(99); + expect(await parseXml({ xml })).toEqual(nest({ depth: 99 })); + }); + + test('fails fast with a clear error past 100 levels of nesting', async () => { + const started = performance.now(); + await expect(buildXml({ json: nest({ depth: 100 }) })).rejects.toThrow('nested more than 100 levels deep'); + await expect(buildXml({ json: nest({ depth: 5000 }) })).rejects.toThrow('nested more than 100 levels deep'); + expect(performance.now() - started).toBeLessThan(1000); + }); + + test('builds 60 levels of nesting quickly with pretty print and round-trips it', async () => { + const json = nest({ depth: 60 }); + const started = performance.now(); + const xml = await buildXml({ json, prettyPrint: true }); + expect(performance.now() - started).toBeLessThan(1000); + expect(await parseXml({ xml })).toEqual(json); + }); + + test('pretty prints an object with 10,000 keys and mixed content quickly', async () => { + const wide = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => [`k${index}`, { '#text': `t${index} `, b: index }])); + const started = performance.now(); + const xml = await buildXml({ json: { r: wide }, prettyPrint: true }); + expect(performance.now() - started).toBeLessThan(5000); + expect(xml).toContain('\n t9999 9999\n'); + }); + + test('picks a placeholder in linear time when the text holds a long run of lookalike characters', async () => { + const text = `apinlinexml${'x'.repeat(200_000)}`; + const started = performance.now(); + const xml = await buildXml({ json: { r: { a: text, p: { '#text': 'mixed ', b: 'bold' } } }, prettyPrint: true }); + expect(performance.now() - started).toBeLessThan(2000); + expect(xml).toBe(`\n ${text}\n

mixed bold

\n
`); + }); + + test('pretty prints a list of 200,000 mixed-content items without a stack overflow', async () => { + const items = Array.from({ length: 200_000 }, (_, index) => ({ '#text': `t${index} `, b: 'x' })); + const xml = await buildXml({ json: { r: { p: items } }, prettyPrint: true }); + expect(xml.endsWith('

t199999 x

\n')).toBe(true); + }, 30_000); + + test('builds a list of 10,000 items', async () => { + const rows = Array.from({ length: 10_000 }, (_, index) => ({ '@_id': String(index), name: `row ${index}`, code: `C-${index}` })); + const started = performance.now(); + const xml = await buildXml({ json: { rows: { row: rows } } }); + expect(performance.now() - started).toBeLessThan(5000); + expect(xml.match(/row 9999C-9999')).toBe(true); + expect(await parseXml({ xml })).toEqual({ rows: { row: rows } }); + }); + }); + + describe('round-trips with Convert XML to JSON', () => { + test.each([ + ['attributes and repeated objects', { catalog: { book: [{ '@_id': '1', title: 'Dune', price: 9.5 }, { '@_id': '2', title: 'Emma', price: 7 }] } }], + ['repeated values', { root: { tag: ['a', 'b', 'c'] } }], + ['text next to an attribute', { price: { '@_currency': 'USD', '#text': 9.5 } }], + ['namespaces in a SOAP envelope', soapEnvelope], + ['escaping in text and attributes', { note: { '@_title': `x"y&<>'`, text: `x & y "q" 'r'` } }], + ['unicode and emoji', { note: { '@_lang': '日本', name: 'café 日本 😀' } }], + ['booleans, zero and negative numbers', { flags: { on: true, off: false, zero: 0, neg: -2.5 } }], + ['empty string', { root: { a: '' } }], + ['nested objects', { a: { b: { c: { d: 'deep' } } } }], + ])('JSON to XML to JSON: %s', async (_name, json) => { + expect(await parseXml({ xml: await buildXml({ json }) })).toEqual(json); + expect(await parseXml({ xml: await buildXml({ json, prettyPrint: true, includeDeclaration: true }) })).toEqual(json); + expect(await parseXml({ xml: await buildXml({ json, selfCloseEmptyElements: true }) })).toEqual(json); + }); + + test('CDATA content survives, but comes back as plain text', async () => { + const xml = await buildXml({ json: { a: { '#cdata': 'bold & more' } } }); + expect(await parseXml({ xml })).toEqual({ a: 'bold & more' }); + }); + + test('leading zeros are written, but Convert XML to JSON reads them back as numbers', async () => { + const xml = await buildXml({ json: { address: { zip: '02134' } } }); + expect(xml).toBe('
02134
'); + expect(await parseXml({ xml })).toEqual({ address: { zip: 2134 } }); + }); + + test('shapes Convert XML to JSON cannot give back unchanged', async () => { + expect(await parseXml({ xml: await buildXml({ json: { r: { tag: ['only'] } } }) })).toEqual({ r: { tag: 'only' } }); + expect(await parseXml({ xml: await buildXml({ json: { r: { id: '123', ratio: '1.50', yes: 'true' } } }) })).toEqual({ r: { id: 123, ratio: 1.5, yes: true } }); + expect(await parseXml({ xml: await buildXml({ json: { r: { '@_n': 5 } } }) })).toEqual({ r: { '@_n': '5' } }); + expect(await parseXml({ xml: await buildXml({ json: { r: { a: null, b: {}, c: [] } } }) })).toEqual({ r: { a: '', b: '' } }); + await expect(parseXml({ xml: await buildXml({ json: JSON.parse('{"r":{"__proto__":1}}') }) })).rejects.toThrow('prototype pollution'); + }); + + test('XML to JSON to XML', async () => { + const xml = + 'DunexyEmma & co7'; + expect(await buildXml({ json: await parseXml({ xml }) })).toBe(xml); + }); + + test('XML to JSON to XML for a SOAP envelope with a declaration', async () => { + const xml = + 'Apples & PearsSW1A 1AA'; + expect(await buildXml({ json: await parseXml({ xml }), includeDeclaration: true })).toBe(xml); + }); + }); +}); + +describe('convertJsonToXml keeps its output unchanged', () => { + test.each([ + ['merges list items into one element', { tags: ['a', 'b'] }, undefined, undefined, 'ab'], + [ + 'copies the attr object onto every child and adds a header', + { person: { name: 'Alice', attr: { id: '1' }, age: 30 } }, + undefined, + true, + 'Alice30', + ], + [ + 'escapes text but not attribute values', + { note: { text: `x & y "q"`, attr: { t: `a"b&` } } }, + undefined, + undefined, + 'x & y <z> "q"', + ], + ['flattens a list of objects', { rows: [{ c: 1 }, { c: 2 }] }, undefined, undefined, '12'], + ['uses a custom attribute key', { item: { meta: { lang: 'en' }, v: null, e: '' } }, 'meta', false, ''], + ])('%s', async (_name, json, attributesKey, header, expected) => { + const ctx = createMockActionContext({ + propsValue: { json, attributes_key: attributesKey, header }, + }); + expect(await convertJsonToXml.run(ctx)).toBe(expected); + }); +}); + +const soapEnvelope = { + 'soap:Envelope': { + '@_xmlns:soap': 'http://schemas.xmlsoap.org/soap/envelope/', + 'soap:Body': { 'm:GetPrice': { '@_xmlns:m': 'https://example.com/prices', 'm:Item': 'Apples' } }, + }, +}; + +async function buildXml({ json, ...options }: BuildXmlParams): Promise { + const ctx = createMockActionContext({ + propsValue: { + rootElement: undefined, + listItemElement: undefined, + prettyPrint: false, + includeDeclaration: false, + selfCloseEmptyElements: false, + attributePrefix: undefined, + textKey: undefined, + cdataKey: undefined, + ...options, + json, + }, + }); + return String(await createXml.run(ctx)); +} + +async function parseXml({ xml }: { xml: string }): Promise { + const ctx = createMockActionContext({ + propsValue: { xml, ignoreAttributes: false }, + }); + return convertXmlToJson.run(ctx); +} + +function seededRandom({ seed }: { seed: number }): (limit: number) => number { + let state = seed; + return (limit) => { + state = (state * 1103515245 + 12345) % 2147483648; + return state % limit; + }; +} + +function randomNode({ random, depth }: { random: (limit: number) => number; depth: number }): unknown { + const values = ['', 'a', 'x & y', ' spaced ', '', 'apinlinexml0_', null, 0, false, 'é😀']; + if (depth > 4) { + return values[random(values.length)]; + } + const kind = random(7); + if (kind === 0) { + return values[random(values.length)]; + } + if (kind === 1) { + return [randomNode({ random, depth: depth + 1 }), randomNode({ random, depth: depth + 1 })]; + } + const attribute = random(2) === 1 ? { '@_id': String(random(9)) } : {}; + const text = random(3) === 0 ? { '#text': values[random(values.length)] } : {}; + const cdata = random(4) === 0 ? { '#cdata': [values[random(values.length)], ''][random(2)] } : {}; + const children = Object.fromEntries( + Array.from({ length: random(3) }, (_, index) => [`${['a', 'b', 'ns:c'][random(3)]}${index || ''}`, randomNode({ random, depth: depth + 1 })]), + ); + return { ...attribute, ...text, ...cdata, ...children }; +} + +function nest({ depth }: { depth: number }): Record { + return Array.from({ length: depth }).reduce>((inner) => ({ n: inner }), { v: 'leaf' }); +} + +type BuildXmlParams = { + json: unknown; + rootElement?: string; + listItemElement?: string; + prettyPrint?: boolean; + includeDeclaration?: boolean; + selfCloseEmptyElements?: boolean; + attributePrefix?: string; + textKey?: string; + cdataKey?: string; +}; diff --git a/packages/server/api/src/app/ee/agent/agent-approval-gate.ts b/packages/server/api/src/app/ee/agent/agent-approval-gate.ts index 31652d2a213c..d8ff53db8a47 100644 --- a/packages/server/api/src/app/ee/agent/agent-approval-gate.ts +++ b/packages/server/api/src/app/ee/agent/agent-approval-gate.ts @@ -6,7 +6,7 @@ const GATE_TTL_SECONDS = 15 * 60 const CANCEL_TTL_SECONDS = 2 * 60 * 60 const CONNECTION_STORE_TTL_SECONDS = 24 * 60 * 60 -const KEY_PREFIX = 'tool-approval-decision:' +const KEY_PREFIX = 'tool-approval-decision:v2:' const CHANNEL_PREFIX = 'tool-approval:' const CANCEL_KEY_PREFIX = 'chat-cancel:' const AVAILABLE_CONNECTIONS_PREFIX = 'chat-conn-avail:' @@ -27,7 +27,8 @@ async function resolveGate({ gateId, approved, payload, log }: { gateId: string, const conversationId = await distributedStore.get(`${PENDING_GATE_PREFIX}gate:${gateId}`) const pendingGate = conversationId ? (await readPendingGates({ conversationId }))[gateId] : undefined const approvedInput = pendingGate?.toolInput - const wasSet = await distributedStore.putIfAbsent(decisionKey(gateId), { approved, payload, approvedInput }, GATE_TTL_SECONDS) + const decision: GateDecision = { approved, payload, approvedInput, approvedToolName: pendingGate?.toolName, approvedConversationId: conversationId ?? undefined, approvedRunId: pendingGate?.runId } + const wasSet = await distributedStore.putIfAbsent(decisionKey(gateId), decision, GATE_TTL_SECONDS) if (wasSet) { await pubsub.publish(channelName(gateId), JSON.stringify({ approved, payload })) if (conversationId) { @@ -44,7 +45,11 @@ async function resolveGate({ gateId, approved, payload, log }: { gateId: string, async function checkDecision({ gateId }: { gateId: string }): Promise { const raw = await distributedStore.get(decisionKey(gateId)) if (!raw) return 'pending' - return { approved: raw.approved === true, payload: raw.payload, approvedInput: raw.approvedInput } + return { approved: raw.approved === true, payload: raw.payload, approvedInput: raw.approvedInput, approvedToolName: raw.approvedToolName, approvedConversationId: raw.approvedConversationId, approvedRunId: raw.approvedRunId } +} + +async function consumeApproval({ gateId }: { gateId: string }): Promise { + return distributedStore.putIfAbsent(`${decisionKey(gateId)}:consumed`, true, GATE_TTL_SECONDS) } async function waitForDecision({ gateId, timeoutMs }: { gateId: string, timeoutMs: number }): Promise { @@ -162,6 +167,7 @@ async function clearPendingGate({ conversationId }: { conversationId: string }): export const agentApprovalGate = { resolveGate, checkDecision, + consumeApproval, waitForDecision, requestCancel, isCancelled, @@ -180,6 +186,9 @@ type GateDecision = { approved: boolean payload?: Record approvedInput?: Record + approvedToolName?: string + approvedConversationId?: string + approvedRunId?: string } type StoredConnection = { diff --git a/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts b/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts index 9b4e3eb58318..4bd6a9514dff 100644 --- a/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts +++ b/packages/server/api/src/app/ee/agent/rpc/tool-execution-rpc.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' import { ActivepiecesAiConsumerSource, ActivepiecesError, ErrorCode, isNil, spreadIfDefined, tryCatch } from '@activepieces/core-utils' import { aiUtils } from '@activepieces/server-utils' import { AGENT_SELF_EDIT_TOOLS, AGENT_SURFACE_TOOLS, AgentActionOutcome, AgentRunSource, agentToolClassification, ExecuteAgentToolRequest, ExecuteAgentToolResponse, ExecuteFlowToolRequest, ExecuteFlowToolResponse, ExecuteKnowledgeBaseToolRequest, ExecuteKnowledgeBaseToolResponse, ExecutePieceToolRequest, ExecutePieceToolResponse, FlowActionType, flowStructureUtil } from '@activepieces/shared' @@ -154,7 +155,8 @@ export const toolExecutionRpc = (log: FastifyBaseLogger) => ({ } if (AGENT_SELF_EDIT_TOOLS.includes(input.toolName) || input.toolName === 'ap_create_agent') { const readAlready = await turnHasRead({ conversationId: input.conversationId ?? '', ...spreadIfDefined('runId', input.runId) }) - if (readAlready) { + const approvedByUser = readAlready && await approvedByHuman({ toolName: input.toolName, toolInput: input.toolInput, conversationId: input.conversationId, runId: input.runId }) + if (readAlready && !approvedByUser) { log.warn({ tool: { name: input.toolName }, source: input.source, conversation: { id: input.conversationId } }, '[agentRpc#executeAgentTool] Refused a saved-agent change for a turn that already read something') throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, @@ -281,6 +283,26 @@ export const toolExecutionRpc = (log: FastifyBaseLogger) => ({ const MAX_APPROVAL_BLOCK_MS = 50_000 const CHAT_ONLY_TOOL_PREFIX = '__' const OWNER_SCOPED_TOOLS = ['ap_remember'] +async function approvedByHuman({ toolName, toolInput, conversationId, runId }: { toolName: string, toolInput: Record, conversationId?: string, runId?: string }): Promise { + const { [APPROVED_GATE_KEY]: gateId, ...requested } = toolInput + if (typeof gateId !== 'string') { + return false + } + const decision = await agentApprovalGate.checkDecision({ gateId }) + const approvedThisCall = decision !== 'pending' + && decision.approved + && decision.approvedToolName === toolName + && decision.approvedConversationId === conversationId + && decision.approvedRunId === runId + && !isNil(decision.approvedInput) + && isDeepStrictEqual(decision.approvedInput, requested) + if (!approvedThisCall) { + return false + } + return agentApprovalGate.consumeApproval({ gateId }) +} + +const APPROVED_GATE_KEY = 'approvedGateId' const ATTENDED_STATE_TOOLS = ['__cancel_check', '__approval_wait', '__store_pending_gate', '__store_selected_connection', '__get_selected_connection'] const SOURCE_EXTRA_TOOLS: Partial> = { [AgentRunSource.AGENT_BUILDER]: AGENT_SURFACE_TOOLS, diff --git a/packages/server/api/test/integration/ee/agent/agent-self-edit.test.ts b/packages/server/api/test/integration/ee/agent/agent-self-edit.test.ts index 4da4b40abbcb..532228f8118f 100644 --- a/packages/server/api/test/integration/ee/agent/agent-self-edit.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-self-edit.test.ts @@ -2,6 +2,7 @@ import { AgentIcon, AgentRunSource, AIProviderName, apId, ApplicationEvent, Appl import { FastifyInstance } from 'fastify' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { agentRpcHandlers } from '../../../../src/app/ee/agent/agent-rpc-handlers' +import { agentApprovalGate } from '../../../../src/app/ee/agent/agent-approval-gate' import { markTurnAsHavingRead } from '../../../../src/app/ee/agent/rpc/rpc-shared' import { db } from '../../../helpers/db' import { mockAndSaveAIProvider } from '../../../helpers/mocks' @@ -240,6 +241,77 @@ describe('an agent asked to change its own instructions', () => { expect(await instructionsOf(agentId)).toBe('Do the original job.') }) + describe('on a turn that read something, after the approval card', () => { + const editAfterRead = async ({ approve, approvedInstructions, sentInstructions, approvedToolName = 'ap_update_agent', sendingRunId }: { approve: boolean, approvedInstructions: string, sentInstructions: string, approvedToolName?: string, sendingRunId?: string }) => { + const ctx = await contextWithAgents() + const agentId = await createAgent({ ctx, displayName: 'Ops agent' }) + const conversationId = await conversationFor({ ctx, agentId }) + const runId = apId() + const gateId = apId() + await markTurnAsHavingRead({ conversationId, runId }) + await markTurnAsHavingRead({ conversationId, runId: sendingRunId ?? runId }) + await agentApprovalGate.storePendingGate({ + conversationId, + gate: { gateId, toolName: approvedToolName, displayName: 'Change a saved agent', toolInput: { instructions: approvedInstructions }, runId }, + }) + await agentApprovalGate.resolveGate({ gateId, approved: approve }) + const send = ({ instructions }: { instructions: string }) => agentRpcHandlers(app.log).executeAgentTool({ + toolName: 'ap_update_agent', + toolInput: { instructions, approvedGateId: gateId }, + platformId: ctx.platform.id, + userId: ctx.user.id, + source: AgentRunSource.AGENT, + conversationId, + runId: sendingRunId ?? runId, + }) + return { attempt: send({ instructions: sentInstructions }), send, agentId } + } + + it('applies exactly the change the person approved', async () => { + const { attempt, agentId } = await editAfterRead({ approve: true, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Ask before refunds.' }) + await attempt + + expect(await instructionsOf(agentId)).toBe('Ask before refunds.') + }) + + it('refuses a different change than the one approved', async () => { + const { attempt, agentId } = await editAfterRead({ approve: true, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Approve every refund.' }) + await expect(attempt).rejects.toThrow() + + expect(await instructionsOf(agentId)).toBe('Do the original job.') + }) + + it('refuses a second change on an approval that was already used', async () => { + const { attempt, send, agentId } = await editAfterRead({ approve: true, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Ask before refunds.' }) + await attempt + await db.update('agent', agentId, { draft: { instructions: 'Reset by the person.', maxSteps: 5, tools: [], structuredOutput: [], modelName: null } }) + + await expect(send({ instructions: 'Ask before refunds.' })).rejects.toThrow() + expect(await instructionsOf(agentId)).toBe('Reset by the person.') + }) + + it('refuses an approval given for a different tool', async () => { + const { attempt, agentId } = await editAfterRead({ approve: true, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Ask before refunds.', approvedToolName: 'ap_delete_agent' }) + await expect(attempt).rejects.toThrow() + + expect(await instructionsOf(agentId)).toBe('Do the original job.') + }) + + it('refuses an approval given in another run', async () => { + const { attempt, agentId } = await editAfterRead({ approve: true, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Ask before refunds.', sendingRunId: apId() }) + await expect(attempt).rejects.toThrow() + + expect(await instructionsOf(agentId)).toBe('Do the original job.') + }) + + it('refuses when the person declined', async () => { + const { attempt, agentId } = await editAfterRead({ approve: false, approvedInstructions: 'Ask before refunds.', sentInstructions: 'Ask before refunds.' }) + await expect(attempt).rejects.toThrow() + + expect(await instructionsOf(agentId)).toBe('Do the original job.') + }) + }) + it('is refused when the turn is not named, so a worker cannot dodge the check by omitting it', async () => { const ctx = await contextWithAgents() const agentId = await createAgent({ ctx, displayName: 'Ops agent' }) diff --git a/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts b/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts index 3963fa1b3065..6973c0b030a3 100644 --- a/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts +++ b/packages/server/sandbox/src/lib/utils/esbuild-build-options.ts @@ -34,6 +34,9 @@ export function stepFolderResolvePlugin(rootDir: string): Plugin { if (args.kind === 'entry-point' || isBareSpecifier(args.path)) { return null } + if (args.path.startsWith('node:')) { + return { external: true } + } if (/^[a-z][a-z0-9+.-]*:/i.test(args.path)) { return { errors: [{ text: IMPORT_OUT_OF_SCOPE_MESSAGE }] } } diff --git a/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts b/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts index b5b0f24d5397..fdf2413efdab 100644 --- a/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts +++ b/packages/server/sandbox/test/lib/utils/esbuild-build-options.test.ts @@ -90,6 +90,22 @@ describe('stepFolderResolvePlugin', () => { expect(result.bundle).not.toContain(SECRET) }) + it('allows a node:-prefixed builtin import', async () => { + const result = await compile({ + code: `import { createHash } from 'node:crypto'; export const code = async () => createHash('md5').update('x').digest('hex')`, + }) + expect(result.built).toBe(true) + expect(result.bundle).toContain('node:crypto') + }) + + it('still blocks non-node scheme imports', async () => { + const result = await compile({ + code: `import s from 'file://${secretDir}/secret.js'; export const code = async () => ({ s })`, + }) + expect(result.built).toBe(false) + expect(result.error).toContain(IMPORT_OUT_OF_SCOPE_MESSAGE) + }) + it('allows a legitimate relative import inside the step folder', async () => { const result = await compile({ code: `import s from './helper'; export const code = async () => ({ s })`, diff --git a/packages/server/utils/src/ai-utils.ts b/packages/server/utils/src/ai-utils.ts index eaf9c61bc7ac..055b39c76460 100644 --- a/packages/server/utils/src/ai-utils.ts +++ b/packages/server/utils/src/ai-utils.ts @@ -1,6 +1,6 @@ -import { ActivepiecesAiBilling, aiChargeFor, AIProviderName, isNil, observedProviderFetch, ProviderOutcomeReporter, spreadIfDefined } from '@activepieces/core-utils'; +import { ActivepiecesAiBilling, aiChargeFor, ActivepiecesError, AIProviderName, ErrorCode, isNil, observedProviderFetch, ProviderOutcomeReporter, spreadIfDefined } from '@activepieces/core-utils'; import { CloudflareGatewayMetadata, createCloudflareGatewayModel, createImageModel, createLanguageModel } from '@activepieces/ai-providers'; -import { AI_PROVIDER_CAPABILITIES, AiProviderCredentials, getEffectiveProviderAndModel } from '@activepieces/shared'; +import { ACTIVEPIECES_IMAGE_TIERS, AI_PROVIDER_CAPABILITIES, AiProviderCredentials, aiProviderUtils, getEffectiveProviderAndModel } from '@activepieces/shared'; import { anthropic } from '@ai-sdk/anthropic' import { createAzure } from '@ai-sdk/azure' import { createGoogleGenerativeAI, google } from '@ai-sdk/google' @@ -10,6 +10,7 @@ import { createOpenRouter, OpenRouterChatSettings } from '@openrouter/ai-sdk-pro import { EmbeddingModel, generateText, ImageModel, LanguageModel, ToolSet } from 'ai' import { billedEmbeddingModel, billedLanguageModel } from './activepieces-ai-cost' import { keyHealthReporterFor } from './ai-provider-key-health' +import { modelTierCatalog } from './model-tier-catalog' const DEFAULT_WEB_SEARCH_RESULTS = 5 const MIN_OPENROUTER_WEB_SEARCH_RESULTS = 1 @@ -132,7 +133,30 @@ function openRouterWebSearchResults(options?: WebSearchOptions): number { ) } -function createModel({ credentials, modelId, metadata, flowStep, billing, turnAlreadyCharged, openaiResponsesModel = false, webSearchEnabled = false, webSearchOptions, platformId, providerConfigId }: { +function managedModelIds(): string[] { + return [ + ...aiProviderUtils.managedChatModelIds(), + ...(['flow', 'chat'] as const).flatMap((surface) => modelTierCatalog.current(surface).tiers.map((tier) => tier.modelId)), + ] +} + +function managedImageModelIds(): string[] { + return ACTIVEPIECES_IMAGE_TIERS.map((tier) => tier.modelId) +} + +function assertManagedModelAllowed({ provider, modelId, image, adminChosenModelId }: { provider: AIProviderName, modelId: string, image: boolean, adminChosenModelId?: string }): void { + const allowed = image ? [...managedImageModelIds(), ...(isNil(adminChosenModelId) ? [] : [adminChosenModelId])] : managedModelIds() + if (provider !== AIProviderName.ACTIVEPIECES || allowed.includes(modelId)) { + return + } + const message = `The model "${modelId}" is not available on Activepieces AI credits. Choose one of the listed models or connect your own AI provider key.` + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message }, + }, message) +} + +function createModel({ credentials, modelId, metadata, flowStep, billing, turnAlreadyCharged, openaiResponsesModel = false, webSearchEnabled = false, webSearchOptions, platformId, providerConfigId, imageGeneration = false, adminChosenImageModelId }: { credentials: AiProviderCredentials modelId: string metadata?: ChatModelMetadata @@ -144,7 +168,10 @@ function createModel({ credentials, modelId, metadata, flowStep, billing, turnAl webSearchOptions?: WebSearchOptions platformId?: string providerConfigId?: string + imageGeneration?: boolean + adminChosenImageModelId?: string }): LanguageModel { + assertManagedModelAllowed({ provider: credentials.provider, modelId, image: imageGeneration, ...spreadIfDefined('adminChosenModelId', adminChosenImageModelId) }) const model = buildModel({ credentials, modelId, metadata, flowStep, openaiResponsesModel, webSearchEnabled, webSearchOptions, onOutcome: keyHealthReporterFor({ platformId, providerConfigId }) }) return billedLanguageModel({ model, @@ -208,11 +235,13 @@ function flowStepMetadataHeaders(flowStep?: FlowStepMetadata): Record ({ + safeHttp: { + get retryingAxios() { + return { get } + }, + }, +})) + +const PUBLISHED_ONLY_MODEL = 'acme/brand-new-model' + +const PUBLISHED = { + version: 1, + publishedAt: '2026-09-29T00:00:00.000Z', + publishedBy: 'ops@activepieces.com', + tiers: [{ id: 'fast', label: 'Fast', modelId: PUBLISHED_ONLY_MODEL, thinkingBudget: 5_000 }], + defaultTierId: 'fast', +} + +describe('aiUtils.createModel on the managed key with a published tier file', () => { + beforeEach(() => { + get.mockReset() + process.env['AP_MODEL_TIERS_URL'] = 'https://tiers.test/pricing.json' + vi.resetModules() + }) + + it('runs a model only the published tiers permit once the file is loaded', async () => { + get.mockResolvedValue({ data: PUBLISHED }) + const { modelTierCatalog } = await import('../src/model-tier-catalog') + const { aiUtils } = await import('../src/ai-utils') + const managed = aiProviderCredentials({ provider: AIProviderName.ACTIVEPIECES, auth: { apiKey: 'sk-managed' }, config: {} }) + + await modelTierCatalog.warmUp() + + expect(() => aiUtils.createModel({ credentials: managed, modelId: PUBLISHED_ONLY_MODEL })).not.toThrow() + }) +}) diff --git a/packages/server/utils/test/ai-utils.test.ts b/packages/server/utils/test/ai-utils.test.ts index 7016d3bb66df..f28a66213d87 100644 --- a/packages/server/utils/test/ai-utils.test.ts +++ b/packages/server/utils/test/ai-utils.test.ts @@ -1,5 +1,5 @@ import { AIProviderName } from '@activepieces/core-utils' -import { aiProviderCredentials } from '@activepieces/shared' +import { ACTIVEPIECES_CHAT_TIERS, ACTIVEPIECES_IMAGE_TIERS, aiProviderCredentials } from '@activepieces/shared' import { describe, expect, it } from 'vitest' import { aiUtils, WebSearchOptions } from '../src/ai-utils' @@ -143,9 +143,39 @@ describe('aiUtils.createModelForImages', () => { }) }) +describe('aiUtils.createModel on the managed Activepieces key', () => { + const managed = aiProviderCredentials({ provider: AIProviderName.ACTIVEPIECES, auth: { apiKey: 'sk-managed' }, config: {} }) + + it('refuses a model that no published tier or allow-list entry permits', () => { + expect(() => aiUtils.createModel({ credentials: managed, modelId: 'perplexity/sonar-deep-research' })) + .toThrow(/not available on Activepieces AI credits/) + }) + + it('runs a model the tiers permit', () => { + expect(() => aiUtils.createModel({ credentials: managed, modelId: ACTIVEPIECES_CHAT_TIERS[0].modelId })).not.toThrow() + }) + + it('refuses an image model outside the managed image tiers', () => { + expect(() => aiUtils.createModelForImages({ credentials: managed, modelId: 'openai/gpt-5-image' })) + .toThrow(/not available on Activepieces AI credits/) + expect(() => aiUtils.createModel({ credentials: managed, modelId: 'openai/gpt-5-image', imageGeneration: true })) + .toThrow(/not available on Activepieces AI credits/) + }) + + it('runs the managed default image model and an image model the admin chose', () => { + expect(() => aiUtils.createModelForImages({ credentials: managed, modelId: ACTIVEPIECES_IMAGE_TIERS[0].modelId })).not.toThrow() + expect(() => aiUtils.createModelForImages({ credentials: managed, modelId: 'openai/gpt-5-image', adminChosenImageModelId: 'openai/gpt-5-image' })).not.toThrow() + }) + + it('leaves a customer-supplied OpenRouter key unrestricted', () => { + const own = aiProviderCredentials({ provider: AIProviderName.OPENROUTER, auth: { apiKey: 'sk-own' }, config: {} }) + expect(() => aiUtils.createModel({ credentials: own, modelId: 'perplexity/sonar-deep-research' })).not.toThrow() + }) +}) + describe('what the managed provider is asked to send back', () => { function settingsFor({ provider, webSearchEnabled = false }: { provider: AIProviderName, webSearchEnabled?: boolean }): Record | undefined { - const model = aiUtils.createModel({ credentials: aiProviderCredentials({ provider, auth: { apiKey: 'key' }, config: {} }), modelId: 'anthropic/claude-sonnet-5', webSearchEnabled }) + const model = aiUtils.createModel({ credentials: aiProviderCredentials({ provider, auth: { apiKey: 'key' }, config: {} }), modelId: ACTIVEPIECES_CHAT_TIERS[0].modelId, webSearchEnabled }) return (model as unknown as { settings?: Record }).settings } diff --git a/packages/server/worker/src/lib/execute/jobs/ai/generate-image.ts b/packages/server/worker/src/lib/execute/jobs/ai/generate-image.ts index 259d9a2e17de..1e141744db23 100644 --- a/packages/server/worker/src/lib/execute/jobs/ai/generate-image.ts +++ b/packages/server/worker/src/lib/execute/jobs/ai/generate-image.ts @@ -24,7 +24,7 @@ export async function generateImageStep({ ctx, data, resolved, flowStep, billing return url } -export async function getGeneratedImage({ credentials, modelId, prompt, advancedOptions, inputImages, flowStep, billing, turnAlreadyCharged = false, aspectRatio, abortSignal }: { +export async function getGeneratedImage({ credentials, modelId, prompt, advancedOptions, inputImages, flowStep, billing, turnAlreadyCharged = false, aspectRatio, abortSignal, adminChosenImageModelId }: { credentials: AiProviderCredentials modelId: string prompt: string @@ -35,8 +35,9 @@ export async function getGeneratedImage({ credentials, modelId, prompt, advanced turnAlreadyCharged?: boolean aspectRatio?: `${number}:${number}` abortSignal?: AbortSignal + adminChosenImageModelId?: string }): Promise { - const model = createImageCapableModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged }) + const model = createImageCapableModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged, adminChosenImageModelId }) const { provider: effectiveProvider } = getEffectiveProviderAndModel({ provider: credentials.provider, model: modelId }) const resolvedProvider = effectiveProvider ?? credentials.provider const hasInputImages = inputImages.length > 0 @@ -71,21 +72,22 @@ export function imageBytesOf(image: GeneratedImage): Buffer { return !isNil(image.base64) && image.base64.length > 0 ? Buffer.from(image.base64, 'base64') : Buffer.from(image.uint8Array) } -function createImageCapableModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged }: { +function createImageCapableModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged, adminChosenImageModelId }: { credentials: AiProviderCredentials modelId: string flowStep?: FlowStepMetadata billing: ActivepiecesAiBilling turnAlreadyCharged: boolean + adminChosenImageModelId?: string }): ImageCapableModel { if (!AI_PROVIDER_CAPABILITIES[credentials.provider].supportsImageGeneration) { throw new Error(`Provider ${credentials.provider} does not support image models`) } - const imageModel = aiUtils.createModelForImages({ credentials, modelId, flowStep }) + const imageModel = aiUtils.createModelForImages({ credentials, modelId, flowStep, ...spreadIfDefined('adminChosenImageModelId', adminChosenImageModelId) }) if (!isNil(imageModel)) { return { kind: 'image', model: imageModel } } - return { kind: 'language', model: aiUtils.createModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged }) } + return { kind: 'language', model: aiUtils.createModel({ credentials, modelId, flowStep, billing, turnAlreadyCharged, imageGeneration: true, ...spreadIfDefined('adminChosenImageModelId', adminChosenImageModelId) }) } } async function generateImageUsingGenerateText({ model, prompt, inputImages, aspectRatio, abortSignal }: { diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index 299372b052ce..8976944d054d 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -696,7 +696,7 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS }) const crossProjectTools = agentWorkerTools.createCrossProjectTools({ executeTool: executeCrossProjectTool, eventEmitter, waitForApproval, onGateOpened: storePendingGate, guides, taintState }) const agentSurfaceTools = agentsAvailable && !dryRun && !discoveryOnly - ? agentWorkerTools.createAgentSurfaceTools({ executeTool: executeCrossProjectTool, taintState }) + ? agentWorkerTools.createAgentSurfaceTools({ executeTool: executeCrossProjectTool, taintState, eventEmitter, waitForApproval, onGateOpened: storePendingGate }) : {} const thinkingTools = agentWorkerTools.createThinkingTools() const phaseTools = agentWorkerTools.createPhaseTools({ onPhaseChange: (phase) => { diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts index 0340a57f9a74..b84d059a9cc0 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/run-agent-turn.ts @@ -1,12 +1,11 @@ import { AIProviderName, ErrorCode, formatPieceError, isNil, isObject, isProviderBillingError, isTransientProviderError, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { agentAiUtils, ContentPartLike } from '@activepieces/server-utils' -import { AgentPhase, AgentRunSource, agentToolClassification, agentToolPhases, AI_PROVIDER_ENTITY_TYPES, aiProviderUtils, apErrorOf, CHAT_CREDITS_PER_TOOL_CALL, chatBilling, ChatToolCall, PersistedAgentPart } from '@activepieces/shared' -import { APICallError, generateText, isLoopFinished, isStepCount, LanguageModel, LanguageModelUsage, ModelMessage, NoSuchToolError, RetryError, StepResultPerformance, StopCondition, streamText, ToolExecutionOptions, ToolSet } from 'ai' +import { AgentPhase, AgentRunSource, agentToolClassification, agentToolPhases, AI_PROVIDER_ENTITY_TYPES, aiProviderUtils, apErrorOf, CHAT_CREDITS_PER_TOOL_CALL, chatBilling, ChatToolCall, PersistedAgentPart, TASK_COMPLETION_TOOL_NAME } from '@activepieces/shared' +import { APICallError, generateText, isLoopFinished, isStepCount, LanguageModel, LanguageModelUsage, ModelMessage, NoSuchToolError, RetryError, StepResultPerformance, StopCondition, streamText, ToolChoice, ToolExecutionOptions, ToolSet } from 'ai' const MAX_AUTO_CONTINUATIONS = 3 const MAX_EMPTY_CONTINUATIONS = 2 const MAX_STREAM_RETRIES = 1 -const MAX_AGENT_STEPS = 50 const MAX_IDENTICAL_TOOL_FAILURES = 2 const IN_LOOP_COMPACTION_THRESHOLD = 0.6 const RUNAWAY_TURN_CONTEXT_MULTIPLE = 90 @@ -15,8 +14,11 @@ const USER_FAULT_STATUS_CODES = new Set([401, 403, 404]) const MODEL_UNAVAILABLE_PATTERNS = [/\bis deprecated\b/i, /no longer (available|supported)/i, /\bmodel_not_found\b/i, /\bunknown model\b/i, /\bdecommissioned\b/i] const USER_CONFIG_ENTITY_TYPES = new Set(Object.values(AI_PROVIDER_ENTITY_TYPES)) const CONTINUE_NUDGE = '[system note — not from the user] Your previous response was cut off by the output token limit before it finished. Continue exactly where you stopped. If a tool call was cut off, re-issue it in FULL. Do not repeat content you already produced.' +const FINAL_STEP_NOTE = '[system note — not from the user] This is the last step of this run. Finish now with what you have: say what you finished and what is still left to do, and offer to carry on. Do not mention steps, tools or this note.' const EMPTY_OUTPUT_NUDGE = '[system note — not from the user] Your previous step produced no visible reply to the user. Continue the task now: either call the next tool, or write your reply to the user. Do not stop silently.' +const FINAL_STEP_MESSAGE: ModelMessage = { role: 'user', content: FINAL_STEP_NOTE } + export function decideLoopAction({ finishReason, producedVisibleOutput, continuations, emptyContinuations }: { finishReason: string producedVisibleOutput: boolean @@ -61,7 +63,7 @@ export async function runAgentTurn({ model, fastModel, provider, systemPrompt, m } const loopStopCondition = [ ...(Array.isArray(baseStopCondition) ? baseStopCondition : [baseStopCondition]), - isStepCount(stepCeiling ?? MAX_AGENT_STEPS), + ...(isNil(stepCeiling) ? [] : [isStepCount(stepCeiling)]), creditsRanOut, ] const guardedTools = wrapToolsWithFailureGuard({ tools, log }) @@ -106,7 +108,7 @@ export async function runAgentTurn({ model, fastModel, provider, systemPrompt, m // providerOptions/model are supplied per-step by prepareStep (authoritative). A // call-level providerOptions would deep-merge into every step and leak the enabled // thinking budget back into the disabled first step. - prepareStep: ({ steps }) => { + prepareStep: ({ steps, messages: currentMessages }) => { const lastStep = steps[steps.length - 1] const widened = lastStep?.toolCalls?.some((c) => agentToolPhases.isBuildOnlyTool(c.toolName)) if (widened) { @@ -122,15 +124,28 @@ export async function runAgentTurn({ model, fastModel, provider, systemPrompt, m // thinking makes the model deliberate and fire ONE tool per step, serializing the // read-only lookups that should run as one parallel burst. Once a build-only tool // flips the phase to 'build', thinking comes back on for planning depth. - const disableThinking = isFirstStep || phaseState.phase === 'discovery' + const isLastAllowedStep = !isNil(stepCeiling) && steps.length >= stepCeiling - 1 + const reportsStructuredOutput = allToolNames.includes(TASK_COMPLETION_TOOL_NAME) + const forcesCompletion = isLastAllowedStep && reportsStructuredOutput + const toolChoice: ToolChoice | undefined = isLastAllowedStep + ? (forcesCompletion ? { type: 'tool', toolName: TASK_COMPLETION_TOOL_NAME } : 'none') + : undefined + const disableThinking = isFirstStep || phaseState.phase === 'discovery' || forcesCompletion const usesFastModel = isFirstStep && !isNil(fastModel) + const phaseTools = agentToolPhases.activeToolsForPhase({ phase: phaseState.phase, allToolNames }).filter((name) => paidToolsAffordable || !chatBilling.isPaidTool(name)) + const activeTools = forcesCompletion ? [TASK_COMPLETION_TOOL_NAME] : phaseTools + const boundedContext = boundContextForStep({ baseMessages: attemptMessages, steps, systemPrompt, provider }) + const stepContext = isLastAllowedStep + ? { messages: [...(boundedContext.messages ?? currentMessages), FINAL_STEP_MESSAGE] } + : boundedContext lastStepModelId = usesFastModel ? fastModelId ?? modelId : modelId return { ...(usesFastModel ? { model: fastModel } : {}), - activeTools: agentToolPhases.activeToolsForPhase({ phase: phaseState.phase, allToolNames }).filter((name) => paidToolsAffordable || !chatBilling.isPaidTool(name)), + activeTools, + ...spreadIfDefined('toolChoice', toolChoice), maxOutputTokens: disableThinking ? maxOutputTokensWithoutThinking : maxOutputTokens, providerOptions: agentAiUtils.buildProviderOptions({ provider, tier, modelId: lastStepModelId, disableThinking }), - ...boundContextForStep({ baseMessages: attemptMessages, steps, systemPrompt, provider }), + ...stepContext, } }, repairToolCall: async ({ toolCall, error }) => { diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/session-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/session-tools.ts index 7c5741aacbe8..b9581b1a5525 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/session-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/session-tools.ts @@ -1,8 +1,8 @@ -import { spreadIfDefined } from '@activepieces/core-utils' +import { spreadIfDefined, tryCatch } from '@activepieces/core-utils' import { AgentOutputField, AgentOutputFieldType, AgentPhase, apId, BuildPlanEvent, TASK_COMPLETION_TOOL_NAME } from '@activepieces/shared' -import { tool, ToolSet } from 'ai' +import { tool, ToolExecutionOptions, ToolSet } from 'ai' import { z } from 'zod' -import { AgentEventEmitter, QUESTION_ICON_NAMES, TaintState } from './tool-primitives' +import { AgentEventEmitter, GateDecision, gateNoResponseMessage, QUESTION_ICON_NAMES, TaintState } from './tool-primitives' export function createLocalTools({ onSetProjectContext, projects }: { onSetProjectContext: (projectId: string | null) => Promise<{ success: boolean, error?: string }> @@ -43,15 +43,25 @@ export function createLocalTools({ onSetProjectContext, projects }: { } } -export function createAgentSurfaceTools({ executeTool, taintState }: { +export function createAgentSurfaceTools({ executeTool, taintState, eventEmitter, waitForApproval, onGateOpened }: { executeTool: (toolName: string, toolInput: Record) => Promise taintState: TaintState + eventEmitter: AgentEventEmitter + waitForApproval: (params: { gateId: string, timeoutMs?: number }) => Promise + onGateOpened?: (params: { gateId: string, toolName: string, displayName: string, toolInput: Record }) => Promise }): ToolSet { - const runUnlessTainted = async (toolName: string, toolInput: Record): Promise => { + const runAfterApprovalIfTainted = async ({ toolName, toolInput, toolCallId }: { toolName: string, toolInput: Record, toolCallId: string }): Promise => { if (taintState.tainted) { - return { error: 'You read the user\'s data in this reply or the one before it, so you cannot change a saved agent now. Say what you would have changed, and offer to do it if they send that request as its own message after a reply that reads no data. The Configure panel is the other way.' } + const label = AGENT_CHANGE_LABELS[toolName] ?? toolName + eventEmitter.emitActionPreview({ toolCallId, pieceName: '', actionName: toolName, actionDisplayName: label, input: toolInput, isBatch: false }) + await tryCatch(async () => onGateOpened?.({ gateId: toolCallId, toolName, displayName: label, toolInput })) + const decision = await waitForApproval({ gateId: toolCallId }) + if (decision.outcome !== 'approved') { + const text = decision.outcome === 'timeout' ? gateNoResponseMessage('agent change approval') : 'The user declined this change to the agent. Do not retry it; ask what they want instead.' + return { error: text } + } } - return executeTool(toolName, toolInput) + return executeTool(toolName, taintState.tainted ? { ...toolInput, approvedGateId: toolCallId } : toolInput) } return { ap_list_agents: tool({ @@ -71,8 +81,8 @@ export function createAgentSurfaceTools({ executeTool, taintState }: { connectionExternalId: z.string().optional().describe('externalId from ap_list_connections, for a piece that needs an account'), publish: z.boolean().optional().describe('Make the agent live with these tools in the same step'), }), - execute: async (toolInput) => { - return runUnlessTainted('ap_add_agent_tool', toolInput) + execute: async (toolInput, { toolCallId }: ToolExecutionOptions) => { + return runAfterApprovalIfTainted({ toolName: 'ap_add_agent_tool', toolInput, toolCallId }) }, }), @@ -84,8 +94,8 @@ export function createAgentSurfaceTools({ executeTool, taintState }: { pieceName: z.string().optional().describe('Full piece name, only needed when the same action name is on two of the agent\'s pieces'), publish: z.boolean().optional().describe('Make the agent live without these tools in the same step'), }), - execute: async (toolInput) => { - return runUnlessTainted('ap_remove_agent_tool', toolInput) + execute: async (toolInput, { toolCallId }: ToolExecutionOptions) => { + return runAfterApprovalIfTainted({ toolName: 'ap_remove_agent_tool', toolInput, toolCallId }) }, }), @@ -98,20 +108,20 @@ export function createAgentSurfaceTools({ executeTool, taintState }: { instructions: z.string().optional().describe('The agent\'s full new standing brief, in second person'), publish: z.boolean().optional().describe('Make the change live for flows and chats in the same step'), }), - execute: async (toolInput) => { - return runUnlessTainted('ap_update_agent', toolInput) + execute: async (toolInput, { toolCallId }: ToolExecutionOptions) => { + return runAfterApprovalIfTainted({ toolName: 'ap_update_agent', toolInput, toolCallId }) }, }), ap_create_agent: tool({ - description: 'Create a saved agent in the active project from a name and instructions. Write the instructions as the agent\'s own standing brief, in second person, covering what it does and what it must not do. Call it before you read any of the user\'s data in this reply: afterwards it is refused until their next message, so stand the agent up first and refine it with ap_update_agent once you have looked at their data.', + description: 'Create a saved agent in the active project from a name and instructions. Write the instructions as the agent\'s own standing brief, in second person, covering what it does and what it must not do. If you have read the user\'s data in this reply, they are asked to approve it first.', inputSchema: z.object({ displayName: z.string().describe('Short name the user will recognise, e.g. "Inbox triage"'), instructions: z.string().describe('The agent\'s standing brief, in second person'), description: z.string().optional().describe('One line on what it is for'), }), - execute: async (toolInput) => { - return runUnlessTainted('ap_create_agent', toolInput) + execute: async (toolInput, { toolCallId }: ToolExecutionOptions) => { + return runAfterApprovalIfTainted({ toolName: 'ap_create_agent', toolInput, toolCallId }) }, }), } @@ -219,3 +229,10 @@ function schemaForOutputField(field: AgentOutputField): z.ZodType { } } + +const AGENT_CHANGE_LABELS: Record = { + ap_add_agent_tool: 'Add tools to a saved agent', + ap_remove_agent_tool: 'Remove tools from a saved agent', + ap_update_agent: 'Change a saved agent', + ap_create_agent: 'Create a saved agent', +} diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/web-media-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/web-media-tools.ts index e9c2b3de9d15..edc30ba95452 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/web-media-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/web-media-tools.ts @@ -253,6 +253,7 @@ export function providerImageGenerator({ credentials, modelId, billing }: { cred turnAlreadyCharged: true, aspectRatio: ASPECT_RATIO_BY_ASPECT[aspectRatio], abortSignal: signal, + adminChosenImageModelId: modelId, }) return { bytes: imageBytesOf(image), diff --git a/packages/server/worker/src/lib/worker.ts b/packages/server/worker/src/lib/worker.ts index ae2cf31557df..8f226747bec4 100644 --- a/packages/server/worker/src/lib/worker.ts +++ b/packages/server/worker/src/lib/worker.ts @@ -2,7 +2,7 @@ import { createServer } from 'http' import os from 'os' import { ActivepiecesError, isNil, spreadIfDefined, tryCatch, tryCatchSync } from '@activepieces/core-utils' import { ACTION_RUN_CACHE_ACTIVE_WINDOW_MS, ACTION_RUN_CACHE_FIRST_SWEEP_DELAY_MS, ACTION_RUN_CACHE_SWEEP_INTERVAL_MS, actionRunCache, cacheUtils, createResolver, createSandboxRuntime, Runtime } from '@activepieces/sandbox' -import { aiCostReporter, createLogger, modelCatalog, systemUsage, wideEvent } from '@activepieces/server-utils' +import { aiCostReporter, createLogger, modelCatalog, modelTierCatalog, systemUsage, wideEvent } from '@activepieces/server-utils' import { ApEdition, ApiToWorkerContract, ConsumeJobRequest, createNotifyServer, createRpcClient, EngineResponseStatus, ExecutionMode, JobData, LONG_RUNNING_RPC_METHODS, SandboxInformation, WebsocketServerEvent, WorkerJobType, WorkerMachineHealthcheckRequest, WorkerProps, WorkerSettingsResponse, WorkerToApiContract } from '@activepieces/shared' import { nanoid } from 'nanoid' import { io, Socket } from 'socket.io-client' @@ -62,6 +62,7 @@ export const worker = { async start({ apiUrl, socketUrl, workerToken, withHealthServer = false }: WorkerStartParams): Promise { versionChecker.assertReleaseReadable() modelCatalog.load().catch(() => undefined) + await modelTierCatalog.warmUp() const workerGroupId = system.get(WorkerSystemProp.WORKER_GROUP_ID) const projectWorker = system.getBoolean(WorkerSystemProp.PROJECT_WORKER) ?? true socket = io(socketUrl.url, { diff --git a/packages/server/worker/test/lib/agent-eval/agent-turn-credits.test.ts b/packages/server/worker/test/lib/agent-eval/agent-turn-credits.test.ts index 2b5467326c78..3d24b60827bf 100644 --- a/packages/server/worker/test/lib/agent-eval/agent-turn-credits.test.ts +++ b/packages/server/worker/test/lib/agent-eval/agent-turn-credits.test.ts @@ -1,5 +1,5 @@ -import { AIProviderName } from '@activepieces/core-utils' -import { PersistedAgentPartType, PersistedToolCallStatus } from '@activepieces/shared' +import { AIProviderName, isNil, spreadIfDefined } from '@activepieces/core-utils' +import { AgentPhase, PersistedAgentPartType, PersistedToolCallStatus } from '@activepieces/shared' import { tool } from 'ai' import { convertArrayToReadableStream, MockLanguageModelV3 } from 'ai/test' import { describe, expect, it, vi } from 'vitest' @@ -50,12 +50,55 @@ describe('a turn that runs out of credits', () => { }) }) -async function runTurn({ search, creditsLeft, stepCeiling = 20, model = alwaysSearchingModel() }: { +describe('a turn with many steps', () => { + it('ends a saved agent\'s step budget with a reply instead of a silent stop', async () => { + const search = vi.fn(async () => SEARCH_RESULT) + const model = searchUntilToldToAnswer({ searches: 10 }) + + const turn = await runTurn({ search, creditsLeft: async () => 100, stepCeiling: 3, model, drainsStream: true }) + + const toolChoices = model.doStreamCalls.map((call) => call.toolChoice?.type) + expect(search).toHaveBeenCalledTimes(2) + expect(toolChoices.at(-1)).toBe('none') + expect(JSON.stringify(model.doStreamCalls.at(-1)?.prompt)).toContain('last step of this run') + expect(turn.uiParts.at(-1)?.type).toBe(PersistedAgentPartType.TEXT) + }) + + it('makes a flow-step agent report its structured output on the last step, so the flow still gets a result', async () => { + const capture = vi.fn() + const model = searchUntilToldToAnswer({ searches: 10 }) + + await runTurn({ search: async () => SEARCH_RESULT, creditsLeft: async () => 100, stepCeiling: 3, model, drainsStream: true, completion: capture, phase: 'build' }) + + const thinkingPerCall = model.doStreamCalls.map((call) => JSON.stringify(call.providerOptions).includes('"enabled"')) + const lastCall = model.doStreamCalls.at(-1) + expect(lastCall?.toolChoice).toEqual({ type: 'tool', toolName: 'updateTaskStatus' }) + expect(thinkingPerCall.slice(1, -1).every(Boolean)).toBe(true) + expect(thinkingPerCall.at(-1)).toBe(false) + expect(capture).toHaveBeenCalledWith({ output: { summary: 'partial' } }) + }) + + it('lets chat finish a long job, since credits, time and context already bound the turn', async () => { + const search = vi.fn(async () => SEARCH_RESULT) + + await runTurn({ search, creditsLeft: async () => 1_000, stepCeiling: null, model: searchUntilToldToAnswer({ searches: 60 }), drainsStream: true }) + + expect(search).toHaveBeenCalledTimes(60) + }) +}) + +async function runTurn({ search, creditsLeft, stepCeiling = 20, model = alwaysSearchingModel(), drainsStream = false, completion, phase = 'discovery' }: { search: () => Promise creditsLeft: (pendingCredits: number) => Promise - stepCeiling?: number + stepCeiling?: number | null model?: MockLanguageModelV3 + drainsStream?: boolean + completion?: (input: unknown) => void + phase?: AgentPhase }): ReturnType { + const completionTools = isNil(completion) + ? {} + : { updateTaskStatus: tool({ description: 'report the result', inputSchema: z.object({ output: z.object({ summary: z.string() }) }), execute: async (input) => { completion(input); return SEARCH_RESULT } }) } return runAgentTurn({ model, provider: AIProviderName.ANTHROPIC, @@ -64,14 +107,16 @@ async function runTurn({ search, creditsLeft, stepCeiling = 20, model = alwaysSe tools: { ap_web_search: tool({ description: 'search the web', inputSchema: z.object({ query: z.string() }), execute: search }), ap_fetch_url: tool({ description: 'read a page', inputSchema: z.object({ url: z.string() }), execute: async () => SEARCH_RESULT }), + ...completionTools, }, - allToolNames: ['ap_web_search', 'ap_fetch_url'], + allToolNames: ['ap_web_search', 'ap_fetch_url', ...Object.keys(completionTools)], tier: TIER, modelId: TIER.modelId, - phaseState: { phase: 'discovery' }, + phaseState: { phase }, abortSignal: new AbortController().signal, log: SILENT_LOG, - stepCeiling, + ...spreadIfDefined('stepCeiling', stepCeiling ?? undefined), + ...(drainsStream ? { sinks: { drainStream: (result) => result.consumeStream() } } : {}), creditsLeft, }) } @@ -96,6 +141,40 @@ function alwaysSearchingModel(): MockLanguageModelV3 { }) } +function searchUntilToldToAnswer({ searches }: { searches: number }): MockLanguageModelV3 { + let calls = 0 + return new MockLanguageModelV3({ + doStream: async ({ toolChoice }) => { + calls++ + const answers = toolChoice?.type === 'none' || calls > searches + if (toolChoice?.type === 'tool') { + return { + stream: convertArrayToReadableStream([ + { type: 'stream-start' as const, warnings: [] }, + { type: 'tool-call' as const, toolCallId: `call-${calls}`, toolName: toolChoice.toolName, input: '{"output":{"summary":"partial"}}' }, + { type: 'finish' as const, finishReason: 'tool-calls' as const, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ]), + } + } + return { + stream: convertArrayToReadableStream(answers + ? [ + { type: 'stream-start' as const, warnings: [] }, + { type: 'text-start' as const, id: 'answer' }, + { type: 'text-delta' as const, id: 'answer', delta: 'Here is what I found so far.' }, + { type: 'text-end' as const, id: 'answer' }, + { type: 'finish' as const, finishReason: 'stop' as const, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ] + : [ + { type: 'stream-start' as const, warnings: [] }, + { type: 'tool-call' as const, toolCallId: `call-${calls}`, toolName: 'ap_web_search', input: '{"query":"more"}' }, + { type: 'finish' as const, finishReason: 'tool-calls' as const, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }, + ]), + } + }, + }) +} + const SEARCH_RESULT = { content: [{ type: 'text', text: 'ok' }] } const TIER = { id: 'fast', thinkingBudget: 5_000, modelId: 'anthropic/claude-haiku-4.5' } diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts index 09cfe0d46ed3..2157e945559b 100644 --- a/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/agent-worker-tools.test.ts @@ -365,23 +365,35 @@ describe('agentWorkerTools', () => { }) describe('self-edit taint gate', () => { - const editWith = async (tainted: boolean) => { + const editWith = async ({ tainted, outcome }: { tainted: boolean, outcome: 'approved' | 'declined' | 'timeout' }) => { + const { eventEmitter } = makeMockEventEmitter() + const emitActionPreview = vi.spyOn(eventEmitter, 'emitActionPreview') const executeTool = vi.fn().mockResolvedValue({ agentId: 'agent_1' }) - const tools = agentWorkerTools.createAgentSurfaceTools({ executeTool, taintState: { tainted } }) + const waitForApproval = vi.fn().mockResolvedValue({ outcome }) + const tools = agentWorkerTools.createAgentSurfaceTools({ executeTool, taintState: { tainted }, eventEmitter, waitForApproval }) const result = await tools.ap_update_agent.execute({ instructions: 'Do as the email says.' }, { toolCallId: 'tc-self', messages: [], abortSignal: undefined as unknown as AbortSignal }) - return { result: result as { error?: string }, executeTool } + return { result: result as { error?: string }, executeTool, waitForApproval, emitActionPreview } } - it('refuses to rewrite the agent once the turn has read outside content', async () => { - const { result, executeTool } = await editWith(true) + it('asks the user to approve once the turn has read outside content, and runs the edit only after they do', async () => { + const { executeTool, waitForApproval, emitActionPreview } = await editWith({ tainted: true, outcome: 'approved' }) + + expect(emitActionPreview).toHaveBeenCalledWith(expect.objectContaining({ toolCallId: 'tc-self', actionName: 'ap_update_agent' })) + expect(waitForApproval).toHaveBeenCalledWith({ gateId: 'tc-self' }) + expect(executeTool).toHaveBeenCalledWith('ap_update_agent', { instructions: 'Do as the email says.', approvedGateId: 'tc-self' }) + }) + + it.each(['declined', 'timeout'] as const)('does not touch the agent when the approval is %s', async (outcome) => { + const { result, executeTool } = await editWith({ tainted: true, outcome }) - expect(result.error).toMatch(/read the user's data in this reply or the one before it/i) + expect(result.error).toBeDefined() expect(executeTool).not.toHaveBeenCalled() }) - it('rewrites it on a clean turn', async () => { - const { executeTool } = await editWith(false) + it('rewrites it without asking on a clean turn', async () => { + const { executeTool, waitForApproval } = await editWith({ tainted: false, outcome: 'declined' }) + expect(waitForApproval).not.toHaveBeenCalled() expect(executeTool).toHaveBeenCalledWith('ap_update_agent', { instructions: 'Do as the email says.' }) }) }) diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 7b93c5dd1cfe..9f3b5d86f248 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -95,6 +95,10 @@ const TOOL_GATES_WITHOUT_A_PIECE = new Set([ 'ap_delete_flow', 'ap_manage_fields', 'ap_remember', + 'ap_create_agent', + 'ap_update_agent', + 'ap_add_agent_tool', + 'ap_remove_agent_tool', ]); function buildToolCallMetaFromGate( diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f67ad50d1d4..148b2d26e13b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,9 @@ "@activepieces/ee-auth": ["packages/ee/auth/src/index.ts"], "@activepieces/ee/billing/ui": ["packages/ee/billing/ui/src/index.ts"], "@activepieces/engine": ["packages/server/engine/src/main.ts"], + "@activepieces/piece-0codekit": [ + "packages/pieces/community/0codekit/src/index.ts" + ], "@activepieces/piece-activecampaign": [ "packages/pieces/community/activecampaign/src/index.ts" ], @@ -346,6 +349,9 @@ "@activepieces/piece-contiguity": [ "packages/pieces/community/contiguity/src/index.ts" ], + "@activepieces/piece-convertapi": [ + "packages/pieces/community/convertapi/src/index.ts" + ], "@activepieces/piece-convertkit": [ "packages/pieces/community/convertkit/src/index.ts" ], @@ -706,6 +712,9 @@ "@activepieces/piece-jotform": [ "packages/pieces/community/jotform/src/index.ts" ], + "@activepieces/piece-jsm-operations": [ + "packages/pieces/community/jsm-operations/src/index.ts" + ], "@activepieces/piece-json": [ "packages/pieces/community/json/src/index.ts" ],