diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index c475f27eda8..cf4f36e6b7f 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -14,6 +14,20 @@ When the user asks you to create a block: 2. Configure all subBlocks with proper types, conditions, and dependencies 3. Wire up tools correctly +## Hard Rule: No Guessed Tool Outputs + +Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs. + +- Do NOT invent block outputs for undocumented tool responses +- Do NOT describe unknown JSON shapes as if they were confirmed +- Do NOT wire fields into the block just because they seem likely to exist + +If the tool outputs are not known, do one of these instead: +1. Ask the user for sample tool responses +2. Ask the user for test credentials so the tool responses can be verified +3. Limit the block to operations whose outputs are documented +4. Leave uncertain outputs out and explicitly tell the user what remains unknown + ## Block Configuration Structure ```typescript @@ -575,6 +589,8 @@ Use `type: 'json'` with a descriptive string when: - It represents a list/array of items - The shape varies by operation +If the output shape is unknown because the underlying tool response is undocumented, you MUST tell the user and stop. Unknown is not the same as variable. Never guess block outputs. + ## V2 Block Pattern When creating V2 blocks (alongside legacy V1): @@ -829,3 +845,4 @@ After creating the block, you MUST validate it against every tool it references: - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) 3. **Verify block outputs** cover the key fields returned by all tools 4. **Verify conditions** — each subBlock should only show for the operations that actually use it +5. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 1336fd5502d..0fccb938b11 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -15,6 +15,21 @@ When the user asks you to create a connector: 3. Create the connector directory and config 4. Register it in the connector registry +## Hard Rule: No Guessed Response Or Document Schemas + +If the service docs do not clearly show the document list response, document fetch response, pagination shape, or metadata fields, you MUST tell the user instead of guessing. + +- Do NOT invent document fields +- Do NOT guess pagination cursors or next-page fields +- Do NOT infer metadata/tag mappings from unrelated endpoints +- Do NOT fabricate `ExternalDocument` content structure from partial docs + +If the source schema is unknown, do one of these instead: +1. Ask the user for sample API responses +2. Ask the user for test credentials so you can verify live payloads +3. Implement only the documented parts of the connector +4. Leave the connector incomplete and explicitly say which fields remain unknown + ## Directory Structure Create files in `apps/sim/connectors/{service}/`: @@ -92,6 +107,8 @@ export const {service}Connector: ConnectorConfig = { } ``` +Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTags` when the source payload shape is documented or live-verified. If not, tell the user and stop rather than guessing. + ### API key connector example ```typescript diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index ecfda6b0fac..ee7d85e8f0e 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -29,6 +29,21 @@ Before writing any code: - Required vs optional parameters - Response structures +### Hard Rule: No Guessed Response Schemas + +If the official docs do not clearly show the response JSON shape for an endpoint, you MUST stop and tell the user exactly which outputs are unknown. + +- Do NOT guess response field names +- Do NOT infer nested JSON paths from related endpoints +- Do NOT invent output properties just because they seem likely +- Do NOT implement `transformResponse` against unverified payload shapes + +If response schemas are missing or incomplete, do one of the following before proceeding: +1. Ask the user for sample responses +2. Ask the user for test credentials so you can verify the live payload +3. Reduce the scope to only endpoints whose response shapes are documented +4. Leave the tool unimplemented and explicitly report why + ## Step 2: Create Tools ### Directory Structure @@ -103,6 +118,7 @@ export const {service}{Action}Tool: ToolConfig = { - Set `optional: true` for outputs that may not exist - Never output raw JSON dumps - extract meaningful fields - When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic +- If you do not know the response JSON shape from docs or verified examples, you MUST tell the user and stop. Never guess outputs or response mappings. ## Step 3: Create Block @@ -450,6 +466,8 @@ If creating V2 versions (API-aligned outputs): - [ ] Verified block subBlocks cover all required tool params with correct conditions - [ ] Verified block outputs match what the tools actually return - [ ] Verified `tools.config.params` correctly maps and coerces all param types +- [ ] Verified every tool output and `transformResponse` path against documented or live-verified JSON responses +- [ ] If any response schema remained unknown, explicitly told the user instead of guessing ## Example Command diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 03dbd68456f..fc79a2d348d 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -14,6 +14,21 @@ When the user asks you to create tools for a service: 2. Create the tools directory structure 3. Generate properly typed tool configurations +## Hard Rule: No Guessed Response Schemas + +If the docs do not clearly show the response JSON for a tool, you MUST tell the user exactly which outputs are unknown and stop short of guessing. + +- Do NOT invent response field names +- Do NOT infer nested paths from nearby endpoints +- Do NOT guess array item shapes +- Do NOT write `transformResponse` against unverified payloads + +If the response shape is unknown, do one of these instead: +1. Ask the user for sample responses +2. Ask the user for test credentials so you can verify live responses +3. Implement only the endpoints whose outputs are documented +4. Leave the tool unimplemented and explicitly say why + ## Directory Structure Create files in `apps/sim/tools/{service}/`: @@ -187,6 +202,8 @@ items: { Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown. +If the response shape is unknown because the docs do not provide it, you MUST tell the user and stop. Unknown is not the same as dynamic. Never guess outputs. + ## Critical Rules for transformResponse ### Handle Nullable Fields @@ -441,7 +458,9 @@ After creating all tools, you MUST validate every tool before finishing: - All output fields match what the API actually returns - No fields are missing from outputs that the API provides - No extra fields are defined in outputs that the API doesn't return + - Every output field and JSON path is backed by docs or live-verified sample responses 3. **Verify consistency** across tools: - Shared types in `types.ts` match all tools that use them - Tool IDs in the barrel export match the tool file definitions - Error handling is consistent (error checks, meaningful messages) +4. **If any response schema is still unknown**, explicitly tell the user instead of guessing diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index fd6df46e505..0f168a65be4 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -14,6 +14,21 @@ You are an expert at creating webhook triggers for Sim. You understand the trigg 3. Create a provider handler if custom auth, formatting, or subscriptions are needed 4. Register triggers and connect them to the block +## Hard Rule: No Guessed Webhook Payload Schemas + +If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or `formatInput` mappings. + +- Do NOT invent payload field names +- Do NOT guess nested event object paths +- Do NOT infer output fields from the UI or marketing docs +- Do NOT write `formatInput` against unverified webhook bodies + +If the payload shape is unknown, do one of these instead: +1. Ask the user for sample webhook payloads +2. Ask the user for a test webhook source so you can inspect a real event +3. Implement only the event registration/setup portions whose payloads are documented +4. Leave the trigger unimplemented and explicitly say which payload fields are unknown + ## Directory Structure ``` diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index ceae7d4542c..1a0024ace07 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -52,6 +52,20 @@ Fetch the official API docs for the service. This is the **source of truth** for Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs. +### Hard Rule: No Guessed Source Schemas + +If the service docs do not clearly show document list responses, document fetch responses, metadata fields, or pagination shapes, you MUST tell the user instead of guessing. + +- Do NOT infer document fields from unrelated endpoints +- Do NOT guess pagination cursors or response wrappers +- Do NOT assume metadata keys that are not documented +- Do NOT treat probable shapes as validated + +If a schema is unknown, validation must explicitly recommend: +1. sample API responses, +2. live test credentials, or +3. trimming the connector to only documented fields. + ## Step 3: Validate API Endpoints For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs: @@ -93,6 +107,7 @@ For **every** API call in the connector (`listDocuments`, `getDocument`, `valida - [ ] Field names extracted match what the API actually returns - [ ] Nullable fields are handled with `?? null` or `|| undefined` - [ ] Error responses are checked before accessing data fields +- [ ] Every extracted field and pagination value is backed by official docs or live-verified sample payloads ## Step 4: Validate OAuth Scopes (if OAuth connector) @@ -304,6 +319,7 @@ After fixing, confirm: 1. `bun run lint` passes 2. TypeScript compiles clean 3. Re-read all modified files to verify fixes are correct +4. Any remaining unknown source schemas were explicitly reported to the user instead of guessed ## Checklist Summary diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index 63a4875798f..d8d243c5012 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -41,6 +41,20 @@ Fetch the official API docs for the service. This is the **source of truth** for - Pagination patterns (which param name, which response field) - Rate limits and error formats +### Hard Rule: No Guessed Response Schemas + +If the official docs do not clearly show the response JSON shape for an endpoint, you MUST tell the user instead of guessing. + +- Do NOT assume field names from nearby endpoints +- Do NOT infer nested JSON paths without evidence +- Do NOT treat "likely" fields as confirmed outputs +- Do NOT accept implementation guesses as valid just because they are defensive + +If a response schema is unknown, the validation must explicitly call that out and require: +1. sample responses from the user, +2. live test credentials for verification, or +3. trimming the tool/block down to only documented fields. + ## Step 3: Validate Tools For **every** tool file, check: @@ -81,6 +95,7 @@ For **every** tool file, check: - [ ] All optional arrays use `?? []` - [ ] Error cases are handled: checks for missing/empty data and returns meaningful error - [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields +- [ ] Every extracted field is backed by official docs or live-verified sample payloads ### Outputs - [ ] All output fields match what the API actually returns @@ -267,6 +282,7 @@ After fixing, confirm: 1. `bun run lint` passes with no fixes needed 2. TypeScript compiles clean (no type errors) 3. Re-read all modified files to verify fixes are correct +4. Any remaining unknown response schemas were explicitly reported to the user instead of guessed ## Checklist Summary diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md index ff1eb775b44..113fcccf486 100644 --- a/.agents/skills/validate-trigger/SKILL.md +++ b/.agents/skills/validate-trigger/SKILL.md @@ -44,6 +44,20 @@ Fetch the service's official webhook documentation. This is the **source of trut - Webhook subscription API (create/delete endpoints, if applicable) - Retry behavior and delivery guarantees +### Hard Rule: No Guessed Webhook Payload Schemas + +If the official docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing. + +- Do NOT invent payload field names +- Do NOT infer nested payload paths without evidence +- Do NOT treat likely event shapes as verified +- Do NOT accept `formatInput` mappings that are not backed by docs or live payloads + +If a payload schema is unknown, validation must explicitly recommend: +1. sample webhook payloads, +2. a live test webhook source, or +3. trimming the trigger to only documented outputs. + ## Step 3: Validate Trigger Definitions ### utils.ts @@ -93,6 +107,7 @@ Fetch the service's official webhook documentation. This is the **source of trut - [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`) - [ ] `null` is used for missing optional fields (not empty strings or empty objects) - [ ] Returns `{ input: { ... } }` — not a bare object +- [ ] Every mapped payload field is backed by official docs or live-verified webhook payloads ### Idempotency - [ ] `extractIdempotencyId` returns a stable, unique key per delivery @@ -195,6 +210,7 @@ After fixing, confirm: 1. `bun run type-check` passes 2. Re-read all modified files to verify fixes are correct 3. Provider handler tests pass (if they exist): `bun test {service}` +4. Any remaining unknown webhook payload schemas were explicitly reported to the user instead of guessed ## Checklist Summary diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2806cd4d314..2f91eebc395 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps) { ) } +export function CrowdStrikeIcon(props: SVGProps) { + return ( + + + + ) +} + export function SearchIcon(props: SVGProps) { return ( ) { > ) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 36f62d1299c..df25ac18b39 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -32,6 +32,7 @@ import { CloudflareIcon, CloudWatchIcon, ConfluenceIcon, + CrowdStrikeIcon, CursorIcon, DagsterIcon, DatabricksIcon, @@ -220,6 +221,7 @@ export const blockTypeToIconMap: Record = { cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, confluence_v2: ConfluenceIcon, + crowdstrike: CrowdStrikeIcon, cursor_v2: CursorIcon, dagster: DagsterIcon, databricks: DatabricksIcon, diff --git a/apps/docs/content/docs/en/tools/crowdstrike.mdx b/apps/docs/content/docs/en/tools/crowdstrike.mdx new file mode 100644 index 00000000000..fac75e1213e --- /dev/null +++ b/apps/docs/content/docs/en/tools/crowdstrike.mdx @@ -0,0 +1,144 @@ +--- +title: CrowdStrike +description: Query CrowdStrike Identity Protection sensors and documented aggregates +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries. + + + +## Tools + +### `crowdstrike_get_sensor_aggregates` + +Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | CrowdStrike Falcon API client ID | +| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | +| `cloud` | string | Yes | CrowdStrike Falcon cloud region | +| `aggregateQuery` | json | Yes | JSON aggregate query body documented by CrowdStrike for sensor aggregates | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `aggregates` | array | Aggregate result groups returned by CrowdStrike | +| ↳ `buckets` | array | Buckets within the aggregate result | +| ↳ `count` | number | Bucket document count | +| ↳ `from` | number | Bucket lower bound | +| ↳ `keyAsString` | string | String representation of the bucket key | +| ↳ `label` | json | Bucket label object | +| ↳ `stringFrom` | string | String lower bound | +| ↳ `stringTo` | string | String upper bound | +| ↳ `subAggregates` | json | Nested aggregate results for this bucket | +| ↳ `to` | number | Bucket upper bound | +| ↳ `value` | number | Bucket metric value | +| ↳ `valueAsString` | string | String representation of the bucket value | +| ↳ `docCountErrorUpperBound` | number | Upper bound for bucket count error | +| ↳ `name` | string | Aggregate result name | +| ↳ `sumOtherDocCount` | number | Document count not included in the returned buckets | +| `count` | number | Number of aggregate result groups returned | + +### `crowdstrike_get_sensor_details` + +Get documented CrowdStrike Identity Protection sensor details for one or more device IDs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | CrowdStrike Falcon API client ID | +| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | +| `cloud` | string | Yes | CrowdStrike Falcon cloud region | +| `ids` | json | Yes | JSON array of CrowdStrike sensor device IDs | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sensors` | array | CrowdStrike identity sensor detail records | +| ↳ `agentVersion` | string | Sensor agent version | +| ↳ `cid` | string | CrowdStrike customer identifier | +| ↳ `deviceId` | string | Sensor device identifier | +| ↳ `heartbeatTime` | number | Last heartbeat timestamp | +| ↳ `hostname` | string | Sensor hostname | +| ↳ `idpPolicyId` | string | Assigned Identity Protection policy ID | +| ↳ `idpPolicyName` | string | Assigned Identity Protection policy name | +| ↳ `ipAddress` | string | Sensor local IP address | +| ↳ `kerberosConfig` | string | Kerberos configuration status | +| ↳ `ldapConfig` | string | LDAP configuration status | +| ↳ `ldapsConfig` | string | LDAPS configuration status | +| ↳ `machineDomain` | string | Machine domain | +| ↳ `ntlmConfig` | string | NTLM configuration status | +| ↳ `osVersion` | string | Operating system version | +| ↳ `rdpToDcConfig` | string | RDP to domain controller configuration status | +| ↳ `smbToDcConfig` | string | SMB to domain controller configuration status | +| ↳ `status` | string | Sensor protection status | +| ↳ `statusCauses` | array | Documented causes behind the current status | +| ↳ `tiEnabled` | string | Threat intelligence enablement status | +| `count` | number | Number of sensors returned | +| `pagination` | json | Pagination metadata when returned by the underlying API | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + +### `crowdstrike_query_sensors` + +Search CrowdStrike identity protection sensors by hostname, IP, or related fields + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `clientId` | string | Yes | CrowdStrike Falcon API client ID | +| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | +| `cloud` | string | Yes | CrowdStrike Falcon cloud region | +| `filter` | string | No | Falcon Query Language filter for identity sensor search | +| `limit` | number | No | Maximum number of sensor records to return | +| `offset` | number | No | Pagination offset for the identity sensor query | +| `sort` | string | No | Sort expression for identity sensor results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sensors` | array | Matching CrowdStrike identity sensor records | +| ↳ `agentVersion` | string | Sensor agent version | +| ↳ `cid` | string | CrowdStrike customer identifier | +| ↳ `deviceId` | string | Sensor device identifier | +| ↳ `heartbeatTime` | number | Last heartbeat timestamp | +| ↳ `hostname` | string | Sensor hostname | +| ↳ `idpPolicyId` | string | Assigned Identity Protection policy ID | +| ↳ `idpPolicyName` | string | Assigned Identity Protection policy name | +| ↳ `ipAddress` | string | Sensor local IP address | +| ↳ `kerberosConfig` | string | Kerberos configuration status | +| ↳ `ldapConfig` | string | LDAP configuration status | +| ↳ `ldapsConfig` | string | LDAPS configuration status | +| ↳ `machineDomain` | string | Machine domain | +| ↳ `ntlmConfig` | string | NTLM configuration status | +| ↳ `osVersion` | string | Operating system version | +| ↳ `rdpToDcConfig` | string | RDP to domain controller configuration status | +| ↳ `smbToDcConfig` | string | SMB to domain controller configuration status | +| ↳ `status` | string | Sensor protection status | +| ↳ `statusCauses` | array | Documented causes behind the current status | +| ↳ `tiEnabled` | string | Threat intelligence enablement status | +| `count` | number | Number of sensors returned | +| `pagination` | json | Pagination metadata \(limit, offset, total\) | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 2a8e6ba0c89..dfc8894b490 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -27,6 +27,7 @@ "cloudformation", "cloudwatch", "confluence", + "crowdstrike", "cursor", "dagster", "databricks", diff --git a/apps/docs/content/docs/en/tools/shopify.mdx b/apps/docs/content/docs/en/tools/shopify.mdx index bbc0ea8cbc6..0502cf7ba5c 100644 --- a/apps/docs/content/docs/en/tools/shopify.mdx +++ b/apps/docs/content/docs/en/tools/shopify.mdx @@ -314,8 +314,8 @@ Cancel an order in your Shopify store | `orderId` | string | Yes | Order ID to cancel \(gid://shopify/Order/123456789\) | | `reason` | string | Yes | Cancellation reason \(CUSTOMER, DECLINED, FRAUD, INVENTORY, STAFF, OTHER\) | | `notifyCustomer` | boolean | No | Whether to notify the customer about the cancellation | -| `refund` | boolean | No | Whether to refund the order | -| `restock` | boolean | No | Whether to restock the inventory | +| `restock` | boolean | Yes | Whether to restock the inventory committed to the order | +| `refundMethod` | json | No | Optional refund method object, for example \{"originalPaymentMethodsRefund": true\} | | `staffNote` | string | No | A note about the cancellation for staff reference | #### Output diff --git a/apps/docs/content/docs/en/tools/trello.mdx b/apps/docs/content/docs/en/tools/trello.mdx index e4d377f6541..c7a6ad57098 100644 --- a/apps/docs/content/docs/en/tools/trello.mdx +++ b/apps/docs/content/docs/en/tools/trello.mdx @@ -1,6 +1,6 @@ --- title: Trello -description: Manage Trello boards and cards +description: Manage Trello lists, cards, and activity --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -28,7 +28,15 @@ Integrating Trello with Sim empowers your agents to manage your team’s tasks, ## Usage Instructions -Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments. +{/* MANUAL-CONTENT-START:usage */} +### Trello OAuth Setup + +Before connecting Trello in Sim, add your Sim app origin to the **Allowed Origins** list for your Trello API key in the Trello Power-Up admin settings. + +Trello's authorization flow redirects back to Sim using a `return_url`. If your Sim origin is not whitelisted in Trello, Trello will block the redirect and the connection flow will fail before Sim can save the token. +{/* MANUAL-CONTENT-END */} + +Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments. @@ -48,48 +56,82 @@ List all lists on a Trello board | Parameter | Type | Description | | --------- | ---- | ----------- | -| `lists` | array | Array of list objects with id, name, closed, pos, and idBoard | +| `lists` | array | Lists on the selected board | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | | `count` | number | Number of lists returned | ### `trello_list_cards` -List all cards on a Trello board +List cards from a Trello board or list #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | -| `listId` | string | No | Trello list ID to filter cards \(24-character hex string\) | +| `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId | +| `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `cards` | array | Array of card objects with id, name, desc, url, board/list IDs, labels, and due date | +| `cards` | array | Cards returned from the selected Trello board or list | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | | `count` | number | Number of cards returned | ### `trello_create_card` -Create a new card on a Trello board +Create a new card in a Trello list #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | | `listId` | string | Yes | Trello list ID \(24-character hex string\) | | `name` | string | Yes | Name/title of the card | | `desc` | string | No | Description of the card | | `pos` | string | No | Position of the card \(top, bottom, or positive float\) | | `due` | string | No | Due date \(ISO 8601 format\) | -| `labels` | string | No | Comma-separated list of label IDs \(24-character hex strings\) | +| `dueComplete` | boolean | No | Whether the due date should be marked complete | +| `labelIds` | array | No | Label IDs to attach to the card | +| `items` | string | No | A Trello label ID | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | object | The created card object with id, name, desc, url, and other properties | +| `card` | json | Created card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | ### `trello_update_card` @@ -111,7 +153,21 @@ Update an existing card on Trello | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | object | The updated card object with id, name, desc, url, and other properties | +| `card` | json | Updated card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | ### `trello_get_actions` @@ -124,13 +180,36 @@ Get activity/actions from a board or card | `boardId` | string | No | Trello board ID \(24-character hex string\). Either boardId or cardId required | | `cardId` | string | No | Trello card ID \(24-character hex string\). Either boardId or cardId required | | `filter` | string | No | Filter actions by type \(e.g., "commentCard,updateCard,createCard" or "all"\) | -| `limit` | number | No | Maximum number of actions to return \(default: 50, max: 1000\) | +| `limit` | number | No | Maximum number of board actions to return | +| `page` | number | No | Page number for action results | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `actions` | array | Array of action objects with type, date, member, and data | +| `actions` | array | Action items \(id, type, date, idMemberCreator, text, memberCreator, card, board, list\) | +| ↳ `id` | string | Action ID | +| ↳ `type` | string | Action type | +| ↳ `date` | string | Action timestamp | +| ↳ `idMemberCreator` | string | ID of the member who created the action | +| ↳ `text` | string | Comment text when present | +| ↳ `memberCreator` | object | Member who created the action | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| ↳ `card` | object | Card referenced by the action | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `shortLink` | string | Short card link | +| ↳ `idShort` | number | Board-local card number | +| ↳ `due` | string | Card due date | +| ↳ `board` | object | Board referenced by the action | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `shortLink` | string | Short board link | +| ↳ `list` | object | List referenced by the action | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | | `count` | number | Number of actions returned | ### `trello_add_comment` @@ -148,6 +227,28 @@ Add a comment to a Trello card | Parameter | Type | Description | | --------- | ---- | ----------- | -| `comment` | object | The created comment object with id, text, date, and member creator | +| `comment` | json | Created comment action \(id, type, date, idMemberCreator, text, memberCreator, card, board, list\) | +| ↳ `id` | string | Action ID | +| ↳ `type` | string | Action type | +| ↳ `date` | string | Action timestamp | +| ↳ `idMemberCreator` | string | ID of the member who created the comment | +| ↳ `text` | string | Comment text | +| ↳ `memberCreator` | object | Member who created the comment | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| ↳ `card` | object | Card referenced by the comment | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `shortLink` | string | Short card link | +| ↳ `idShort` | number | Board-local card number | +| ↳ `due` | string | Card due date | +| ↳ `board` | object | Board referenced by the comment | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `shortLink` | string | Short board link | +| ↳ `list` | object | List referenced by the comment | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | diff --git a/apps/docs/content/docs/en/tools/whatsapp.mdx b/apps/docs/content/docs/en/tools/whatsapp.mdx index 959cd540b05..be8015ba50e 100644 --- a/apps/docs/content/docs/en/tools/whatsapp.mdx +++ b/apps/docs/content/docs/en/tools/whatsapp.mdx @@ -34,15 +34,16 @@ Integrate WhatsApp into the workflow. Can send messages. ### `whatsapp_send_message` -Send WhatsApp messages +Send a text message through the WhatsApp Cloud API. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `message` | string | Yes | Message content to send \(plain text or template content\) | +| `message` | string | Yes | Plain text message content to send | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | +| `previewUrl` | boolean | No | Whether WhatsApp should try to render a link preview for the first URL in the message | #### Output @@ -50,8 +51,12 @@ Send WhatsApp messages | --------- | ---- | ----------- | | `success` | boolean | WhatsApp message send success status | | `messageId` | string | Unique WhatsApp message identifier | -| `phoneNumber` | string | Recipient phone number | -| `status` | string | Message delivery status | -| `timestamp` | string | Message send timestamp | +| `messageStatus` | string | Initial delivery state returned by the API | +| `messagingProduct` | string | Messaging product returned by the API | +| `inputPhoneNumber` | string | Recipient phone number echoed back by WhatsApp | +| `whatsappUserId` | string | WhatsApp user ID resolved for the recipient | +| `contacts` | array | Recipient contact records returned by WhatsApp | +| ↳ `input` | string | Input phone number sent to the API | +| ↳ `wa_id` | string | WhatsApp user ID associated with the recipient | diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ddf28d4d7a6..a29451dcdd0 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -32,6 +32,7 @@ import { CloudflareIcon, CloudWatchIcon, ConfluenceIcon, + CrowdStrikeIcon, CursorIcon, DagsterIcon, DatabricksIcon, @@ -220,6 +221,7 @@ export const blockTypeToIconMap: Record = { cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, confluence_v2: ConfluenceIcon, + crowdstrike: CrowdStrikeIcon, cursor_v2: CursorIcon, dagster: DagsterIcon, databricks: DatabricksIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index e36eb70b4d3..241e229ae52 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -51,8 +51,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["secrets-management", "identity"], - "integrationTypes": ["security"] + "integrationTypes": ["security"], + "tags": ["secrets-management", "identity"] }, { "type": "a2a", @@ -102,8 +102,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["agentic", "automation"], - "integrationTypes": ["developer-tools", "ai"] + "integrationTypes": ["developer-tools", "ai"], + "tags": ["agentic", "automation"] }, { "type": "agentmail", @@ -205,8 +205,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["messaging"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["messaging"] }, { "type": "ahrefs", @@ -256,8 +256,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["seo", "marketing", "data-analytics"], - "integrationTypes": ["analytics", "search"] + "integrationTypes": ["analytics", "search"], + "tags": ["seo", "marketing", "data-analytics"] }, { "type": "airtable", @@ -313,8 +313,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["spreadsheet", "automation"], - "integrationTypes": ["databases", "productivity"] + "integrationTypes": ["databases", "developer-tools", "productivity"], + "tags": ["spreadsheet", "automation"] }, { "type": "airweave", @@ -331,8 +331,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["vector-search", "knowledge-base"], - "integrationTypes": ["search", "ai", "documents"] + "integrationTypes": ["search", "ai", "documents"], + "tags": ["vector-search", "knowledge-base"] }, { "type": "algolia", @@ -410,8 +410,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["vector-search", "knowledge-base"], - "integrationTypes": ["search", "ai", "documents"] + "integrationTypes": ["search", "ai", "documents"], + "tags": ["vector-search", "knowledge-base"] }, { "type": "dynamodb", @@ -457,8 +457,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-warehouse"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["cloud", "data-warehouse"] }, { "type": "rds", @@ -500,8 +500,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-warehouse"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["cloud", "data-warehouse"] }, { "type": "sqs", @@ -523,8 +523,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "messaging", "automation"], - "integrationTypes": ["developer-tools", "communication"] + "integrationTypes": ["developer-tools", "communication"], + "tags": ["cloud", "messaging", "automation"] }, { "type": "amplitude", @@ -586,8 +586,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["data-analytics", "marketing"], - "integrationTypes": ["analytics"] + "integrationTypes": ["analytics"], + "tags": ["data-analytics", "marketing"] }, { "type": "apify", @@ -613,8 +613,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "automation", "data-analytics"], - "integrationTypes": ["analytics", "search"] + "integrationTypes": ["search", "analytics", "developer-tools"], + "tags": ["web-scraping", "automation", "data-analytics"] }, { "type": "apollo", @@ -732,8 +732,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["enrichment", "sales-engagement"], - "integrationTypes": ["sales"] + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] }, { "type": "arxiv", @@ -763,8 +763,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["document-processing", "knowledge-base"], - "integrationTypes": ["search", "documents"] + "integrationTypes": ["search", "documents"], + "tags": ["document-processing", "knowledge-base"] }, { "type": "asana", @@ -806,8 +806,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["project-management", "ticketing", "automation"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity", "customer-support", "developer-tools"], + "tags": ["project-management", "ticketing", "automation"] }, { "type": "ashby", @@ -968,8 +968,8 @@ "triggerCount": 6, "authType": "api-key", "category": "tools", - "tags": ["hiring"], - "integrationTypes": ["hr"] + "integrationTypes": ["hr"], + "tags": ["hiring"] }, { "type": "athena", @@ -1019,8 +1019,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-analytics"], - "integrationTypes": ["analytics", "developer-tools"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["cloud", "data-analytics"] }, { "type": "attio", @@ -1313,8 +1313,8 @@ "triggerCount": 22, "authType": "oauth", "category": "tools", - "tags": ["sales-engagement", "enrichment"], - "integrationTypes": ["crm", "sales"] + "integrationTypes": ["crm", "sales"], + "tags": ["sales-engagement", "enrichment"] }, { "type": "secrets_manager", @@ -1352,8 +1352,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "secrets-management"], - "integrationTypes": ["developer-tools", "security"] + "integrationTypes": ["developer-tools", "security"], + "tags": ["cloud", "secrets-management"] }, { "type": "textract_v2", @@ -1370,8 +1370,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["document-processing", "ocr", "cloud"], - "integrationTypes": ["ai", "developer-tools", "documents"] + "integrationTypes": ["ai", "developer-tools", "documents"], + "tags": ["document-processing", "ocr", "cloud"] }, { "type": "microsoft_ad", @@ -1441,8 +1441,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["identity", "microsoft-365"], - "integrationTypes": ["security"] + "integrationTypes": ["security"], + "tags": ["identity", "microsoft-365"] }, { "type": "box", @@ -1520,8 +1520,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["cloud", "content-management", "e-signatures"], - "integrationTypes": ["file-storage", "developer-tools", "documents"] + "integrationTypes": ["file-storage", "developer-tools", "documents"], + "tags": ["cloud", "content-management", "e-signatures"] }, { "type": "brandfetch", @@ -1547,8 +1547,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["enrichment", "marketing"], - "integrationTypes": ["sales", "analytics"] + "integrationTypes": ["sales", "analytics"], + "tags": ["enrichment", "marketing"] }, { "type": "browser_use", @@ -1565,8 +1565,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "automation", "agentic"], - "integrationTypes": ["ai", "search"] + "integrationTypes": ["ai", "developer-tools", "search"], + "tags": ["web-scraping", "automation", "agentic"] }, { "type": "calcom", @@ -1706,8 +1706,8 @@ "triggerCount": 9, "authType": "oauth", "category": "tools", - "tags": ["scheduling", "calendar", "meeting"], - "integrationTypes": ["productivity", "communication"] + "integrationTypes": ["productivity", "communication"], + "tags": ["scheduling", "calendar", "meeting"] }, { "type": "calendly", @@ -1774,8 +1774,8 @@ "triggerCount": 4, "authType": "api-key", "category": "tools", - "tags": ["scheduling", "calendar", "meeting"], - "integrationTypes": ["productivity", "communication"] + "integrationTypes": ["productivity", "communication"], + "tags": ["scheduling", "calendar", "meeting"] }, { "type": "circleback", @@ -1808,8 +1808,8 @@ "triggerCount": 3, "authType": "none", "category": "triggers", - "tags": ["meeting", "note-taking", "automation"], - "integrationTypes": ["ai", "communication", "documents", "productivity"] + "integrationTypes": ["ai", "communication", "developer-tools", "documents", "productivity"], + "tags": ["meeting", "note-taking", "automation"] }, { "type": "clay", @@ -1826,8 +1826,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["enrichment", "sales-engagement", "data-analytics"], - "integrationTypes": ["sales", "analytics"] + "integrationTypes": ["sales", "analytics"], + "tags": ["enrichment", "sales-engagement", "data-analytics"] }, { "type": "clerk", @@ -1889,8 +1889,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["identity", "automation"], - "integrationTypes": ["security"] + "integrationTypes": ["security", "developer-tools"], + "tags": ["identity", "automation"] }, { "type": "cloudflare", @@ -1960,8 +1960,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["cloud", "monitoring"], - "integrationTypes": ["developer-tools", "analytics"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["cloud", "monitoring"] }, { "type": "cloudformation", @@ -2007,8 +2007,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["cloud"] }, { "type": "cloudwatch", @@ -2058,8 +2058,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "monitoring"], - "integrationTypes": ["analytics", "developer-tools"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["cloud", "monitoring"] }, { "type": "confluence_v2", @@ -2342,8 +2342,39 @@ "triggerCount": 16, "authType": "oauth", "category": "tools", - "tags": ["knowledge-base", "content-management", "note-taking"], - "integrationTypes": ["documents", "productivity", "search"] + "integrationTypes": ["documents", "productivity", "search"], + "tags": ["knowledge-base", "content-management", "note-taking"] + }, + { + "type": "crowdstrike", + "slug": "crowdstrike", + "name": "CrowdStrike", + "description": "Query CrowdStrike Identity Protection sensors and documented aggregates", + "longDescription": "Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.", + "bgColor": "#E01F3D", + "iconName": "CrowdStrikeIcon", + "docsUrl": "https://docs.sim.ai/tools/crowdstrike", + "operations": [ + { + "name": "Query Sensors", + "description": "Search CrowdStrike identity protection sensors by hostname, IP, or related fields" + }, + { + "name": "Get Sensor Details", + "description": "Get documented CrowdStrike Identity Protection sensor details for one or more device IDs" + }, + { + "name": "Get Sensor Aggregates", + "description": "Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body" + } + ], + "operationCount": 3, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["security", "analytics", "developer-tools"], + "tags": ["monitoring", "security"] }, { "type": "cursor_v2", @@ -2397,8 +2428,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["agentic", "automation"], - "integrationTypes": ["developer-tools", "ai"] + "integrationTypes": ["developer-tools", "ai"], + "tags": ["agentic", "automation"] }, { "type": "dagster", @@ -2472,8 +2503,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["data-analytics", "automation"], - "integrationTypes": ["analytics"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["data-analytics", "automation"] }, { "type": "databricks", @@ -2523,8 +2554,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["data-warehouse", "data-analytics", "cloud"], - "integrationTypes": ["databases", "analytics", "developer-tools"] + "integrationTypes": ["databases", "analytics", "developer-tools"], + "tags": ["data-warehouse", "data-analytics", "cloud"] }, { "type": "datadog", @@ -2590,8 +2621,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["monitoring", "incident-management", "error-tracking"], - "integrationTypes": ["analytics", "developer-tools"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["monitoring", "incident-management", "error-tracking"] }, { "type": "devin", @@ -2625,8 +2656,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["agentic", "automation"], - "integrationTypes": ["developer-tools", "ai"] + "integrationTypes": ["developer-tools", "ai"], + "tags": ["agentic", "automation"] }, { "type": "discord", @@ -2784,8 +2815,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["messaging", "webhooks", "automation"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "webhooks", "automation"] }, { "type": "docusign", @@ -2835,8 +2866,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["e-signatures", "document-processing"], - "integrationTypes": ["documents"] + "integrationTypes": ["documents"], + "tags": ["e-signatures", "document-processing"] }, { "type": "dropbox", @@ -2894,8 +2925,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["cloud", "document-processing"], - "integrationTypes": ["file-storage", "developer-tools", "documents"] + "integrationTypes": ["file-storage", "developer-tools", "documents"], + "tags": ["cloud", "document-processing"] }, { "type": "dspy", @@ -2925,8 +2956,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "agentic", "automation"], - "integrationTypes": ["ai"] + "integrationTypes": ["ai", "developer-tools"], + "tags": ["llm", "agentic", "automation"] }, { "type": "dub", @@ -2972,8 +3003,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["link-management", "marketing", "data-analytics"], - "integrationTypes": ["analytics"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["link-management", "marketing", "data-analytics"] }, { "type": "duckduckgo", @@ -2990,8 +3021,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["web-scraping", "seo"], - "integrationTypes": ["search", "analytics"] + "integrationTypes": ["search", "analytics"], + "tags": ["web-scraping", "seo"] }, { "type": "elasticsearch", @@ -3061,8 +3092,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["vector-search", "data-analytics"], - "integrationTypes": ["databases", "ai", "analytics", "search"] + "integrationTypes": ["databases", "ai", "analytics", "search"], + "tags": ["vector-search", "data-analytics"] }, { "type": "elevenlabs", @@ -3079,8 +3110,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["text-to-speech"], - "integrationTypes": ["ai"] + "integrationTypes": ["ai"], + "tags": ["text-to-speech"] }, { "type": "openai", @@ -3097,8 +3128,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "vector-search"], - "integrationTypes": ["ai", "search"] + "integrationTypes": ["ai", "search"], + "tags": ["llm", "vector-search"] }, { "type": "enrich", @@ -3232,8 +3263,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["enrichment", "data-analytics"], - "integrationTypes": ["sales", "analytics"] + "integrationTypes": ["sales", "analytics"], + "tags": ["enrichment", "data-analytics"] }, { "type": "evernote", @@ -3295,8 +3326,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["note-taking", "knowledge-base"], - "integrationTypes": ["documents", "productivity", "search"] + "integrationTypes": ["documents", "productivity", "search"], + "tags": ["note-taking", "knowledge-base"] }, { "type": "exa", @@ -3334,8 +3365,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "enrichment"], - "integrationTypes": ["search", "sales"] + "integrationTypes": ["search", "sales"], + "tags": ["web-scraping", "enrichment"] }, { "type": "extend_v2", @@ -3352,8 +3383,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["document-processing", "ocr"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["document-processing", "ocr"] }, { "type": "fathom", @@ -3402,8 +3433,8 @@ "triggerCount": 2, "authType": "api-key", "category": "tools", - "tags": ["meeting", "note-taking"], - "integrationTypes": ["analytics", "communication", "documents", "productivity"] + "integrationTypes": ["analytics", "communication", "documents", "productivity"], + "tags": ["meeting", "note-taking"] }, { "type": "file_v3", @@ -3433,8 +3464,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["document-processing"], - "integrationTypes": ["file-storage", "documents"] + "integrationTypes": ["file-storage", "documents"], + "tags": ["document-processing"] }, { "type": "firecrawl", @@ -3476,8 +3507,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "automation"], - "integrationTypes": ["search"] + "integrationTypes": ["search", "developer-tools"], + "tags": ["web-scraping", "automation"] }, { "type": "fireflies_v2", @@ -3541,8 +3572,8 @@ "triggerCount": 1, "authType": "api-key", "category": "tools", - "tags": ["meeting", "speech-to-text", "note-taking"], - "integrationTypes": ["ai", "communication", "documents", "productivity"] + "integrationTypes": ["productivity", "ai", "communication", "documents"], + "tags": ["meeting", "speech-to-text", "note-taking"] }, { "type": "gamma", @@ -3580,8 +3611,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["document-processing", "content-management"], - "integrationTypes": ["design", "documents"] + "integrationTypes": ["design", "documents"], + "tags": ["document-processing", "content-management"] }, { "type": "github_v2", @@ -3987,8 +4018,8 @@ "triggerCount": 11, "authType": "api-key", "category": "tools", - "tags": ["version-control", "ci-cd"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["version-control", "ci-cd"] }, { "type": "gitlab", @@ -4082,8 +4113,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["version-control", "ci-cd"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["version-control", "ci-cd"] }, { "type": "gmail_v2", @@ -4155,8 +4186,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "messaging"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["google-workspace", "messaging"] }, { "type": "gong", @@ -4257,8 +4288,8 @@ "triggerCount": 2, "authType": "none", "category": "tools", - "tags": ["meeting", "sales-engagement", "speech-to-text"], - "integrationTypes": ["sales", "ai", "communication", "productivity"] + "integrationTypes": ["sales", "ai", "communication", "productivity"], + "tags": ["meeting", "sales-engagement", "speech-to-text"] }, { "type": "google_ads", @@ -4300,8 +4331,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["marketing", "google-workspace", "data-analytics"], - "integrationTypes": ["analytics"] + "integrationTypes": ["analytics"], + "tags": ["marketing", "google-workspace", "data-analytics"] }, { "type": "google_bigquery", @@ -4339,8 +4370,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["data-warehouse", "google-workspace", "data-analytics"], - "integrationTypes": ["databases", "analytics"] + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "google-workspace", "data-analytics"] }, { "type": "google_books", @@ -4366,8 +4397,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "knowledge-base", "content-management"], - "integrationTypes": ["search", "documents"] + "integrationTypes": ["search", "documents"], + "tags": ["google-workspace", "knowledge-base", "content-management"] }, { "type": "google_calendar_v2", @@ -4431,8 +4462,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["calendar", "scheduling", "google-workspace"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity"], + "tags": ["calendar", "scheduling", "google-workspace"] }, { "type": "google_contacts", @@ -4474,8 +4505,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "customer-support", "enrichment"], - "integrationTypes": ["crm", "productivity"] + "integrationTypes": ["productivity", "customer-support", "sales"], + "tags": ["google-workspace", "customer-support", "enrichment"] }, { "type": "google_docs", @@ -4505,8 +4536,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "document-processing", "content-management"], - "integrationTypes": ["documents"] + "integrationTypes": ["documents"], + "tags": ["google-workspace", "document-processing", "content-management"] }, { "type": "google_drive", @@ -4586,8 +4617,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["cloud", "google-workspace", "document-processing"], - "integrationTypes": ["file-storage", "developer-tools", "documents"] + "integrationTypes": ["file-storage", "developer-tools", "documents"], + "tags": ["cloud", "google-workspace", "document-processing"] }, { "type": "google_forms", @@ -4647,8 +4678,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "forms", "data-analytics"], - "integrationTypes": ["productivity", "analytics"] + "integrationTypes": ["documents", "analytics", "productivity"], + "tags": ["google-workspace", "forms", "data-analytics"] }, { "type": "google_groups", @@ -4730,8 +4761,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "messaging", "identity"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "security"], + "tags": ["google-workspace", "messaging", "identity"] }, { "type": "google_maps", @@ -4801,8 +4832,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "enrichment"], - "integrationTypes": ["search"] + "integrationTypes": ["developer-tools", "sales"], + "tags": ["google-workspace", "enrichment"] }, { "type": "google_meet", @@ -4844,8 +4875,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["meeting", "google-workspace", "scheduling"], - "integrationTypes": ["communication", "productivity"] + "integrationTypes": ["communication", "productivity"], + "tags": ["meeting", "google-workspace", "scheduling"] }, { "type": "google_pagespeed", @@ -4862,8 +4893,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "seo", "monitoring"], - "integrationTypes": ["analytics", "developer-tools", "search"] + "integrationTypes": ["analytics", "developer-tools", "search"], + "tags": ["google-workspace", "seo", "monitoring"] }, { "type": "google_search", @@ -4880,8 +4911,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "web-scraping", "seo"], - "integrationTypes": ["search", "analytics"] + "integrationTypes": ["search", "analytics"], + "tags": ["google-workspace", "web-scraping", "seo"] }, { "type": "google_sheets_v2", @@ -4949,8 +4980,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["spreadsheet", "google-workspace", "data-analytics"], - "integrationTypes": ["documents", "analytics", "databases", "productivity"] + "integrationTypes": ["documents", "analytics", "databases", "productivity"], + "tags": ["spreadsheet", "google-workspace", "data-analytics"] }, { "type": "google_slides_v2", @@ -5024,8 +5055,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "document-processing", "content-management"], - "integrationTypes": ["documents"] + "integrationTypes": ["documents"], + "tags": ["google-workspace", "document-processing", "content-management"] }, { "type": "google_tasks", @@ -5067,8 +5098,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "project-management", "scheduling"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity"], + "tags": ["google-workspace", "project-management", "scheduling"] }, { "type": "google_translate", @@ -5094,8 +5125,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "content-management", "automation"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "developer-tools", "documents"], + "tags": ["google-workspace", "content-management", "automation"] }, { "type": "google_vault", @@ -5141,8 +5172,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["google-workspace", "secrets-management", "document-processing"], - "integrationTypes": ["security", "documents"] + "integrationTypes": ["security", "documents"], + "tags": ["google-workspace", "secrets-management", "document-processing"] }, { "type": "grafana", @@ -5236,8 +5267,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["monitoring", "data-analytics"], - "integrationTypes": ["analytics", "developer-tools"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["monitoring", "data-analytics"] }, { "type": "grain", @@ -5332,8 +5363,8 @@ "triggerCount": 8, "authType": "api-key", "category": "tools", - "tags": ["meeting", "note-taking"], - "integrationTypes": ["productivity", "ai", "communication", "documents"] + "integrationTypes": ["productivity", "communication", "documents"], + "tags": ["meeting", "note-taking"] }, { "type": "granola", @@ -5359,8 +5390,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["meeting", "note-taking"], - "integrationTypes": ["productivity", "communication", "documents"] + "integrationTypes": ["productivity", "communication", "documents"], + "tags": ["meeting", "note-taking"] }, { "type": "greenhouse", @@ -5463,8 +5494,8 @@ "triggerCount": 8, "authType": "api-key", "category": "tools", - "tags": ["hiring"], - "integrationTypes": ["hr"] + "integrationTypes": ["hr"], + "tags": ["hiring"] }, { "type": "greptile", @@ -5498,8 +5529,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["version-control", "knowledge-base"], - "integrationTypes": ["developer-tools", "documents", "search"] + "integrationTypes": ["developer-tools", "documents", "search"], + "tags": ["version-control", "knowledge-base"] }, { "type": "hex", @@ -5581,8 +5612,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["data-warehouse", "data-analytics"], - "integrationTypes": ["analytics", "databases"] + "integrationTypes": ["analytics", "databases"], + "tags": ["data-warehouse", "data-analytics"] }, { "type": "hubspot", @@ -5852,8 +5883,8 @@ "triggerCount": 27, "authType": "oauth", "category": "tools", - "tags": ["marketing", "sales-engagement", "customer-support"], - "integrationTypes": ["crm", "analytics", "customer-support", "sales"] + "integrationTypes": ["crm", "analytics", "customer-support", "sales"], + "tags": ["marketing", "sales-engagement", "customer-support"] }, { "type": "huggingface", @@ -5870,8 +5901,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "agentic"], - "integrationTypes": ["ai"] + "integrationTypes": ["ai"], + "tags": ["llm", "agentic"] }, { "type": "hunter", @@ -5913,8 +5944,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["enrichment", "sales-engagement"], - "integrationTypes": ["sales"] + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] }, { "type": "image_generator", @@ -5931,8 +5962,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["image-generation", "llm"], - "integrationTypes": ["ai", "design"] + "integrationTypes": ["ai", "design"], + "tags": ["image-generation", "llm"] }, { "type": "imap", @@ -5955,8 +5986,8 @@ "triggerCount": 1, "authType": "none", "category": "triggers", - "tags": ["messaging", "automation"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication", "developer-tools"], + "tags": ["messaging", "automation"] }, { "type": "incidentio", @@ -6150,8 +6181,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["incident-management", "monitoring"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["incident-management", "monitoring"] }, { "type": "infisical", @@ -6189,8 +6220,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["secrets-management"], - "integrationTypes": ["security"] + "integrationTypes": ["security"], + "tags": ["secrets-management"] }, { "type": "intercom_v2", @@ -6363,8 +6394,8 @@ "triggerCount": 6, "authType": "api-key", "category": "tools", - "tags": ["customer-support", "messaging"], - "integrationTypes": ["customer-support", "communication"] + "integrationTypes": ["customer-support", "communication"], + "tags": ["customer-support", "messaging"] }, { "type": "jina", @@ -6390,8 +6421,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "knowledge-base"], - "integrationTypes": ["search", "documents"] + "integrationTypes": ["search", "documents"], + "tags": ["web-scraping", "knowledge-base"] }, { "type": "jira", @@ -6536,8 +6567,8 @@ "triggerCount": 6, "authType": "oauth", "category": "tools", - "tags": ["project-management", "ticketing"], - "integrationTypes": ["productivity", "customer-support"] + "integrationTypes": ["productivity", "customer-support"], + "tags": ["project-management", "ticketing"] }, { "type": "jira_service_management", @@ -6651,8 +6682,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["customer-support", "ticketing", "incident-management"], - "integrationTypes": ["customer-support", "developer-tools", "productivity"] + "integrationTypes": ["customer-support", "developer-tools", "productivity"], + "tags": ["customer-support", "ticketing", "incident-management"] }, { "type": "kalshi_v2", @@ -6738,8 +6769,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["prediction-markets", "data-analytics"], - "integrationTypes": ["analytics"] + "integrationTypes": ["analytics"], + "tags": ["prediction-markets", "data-analytics"] }, { "type": "ketch", @@ -6777,8 +6808,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["identity"], - "integrationTypes": ["security"] + "integrationTypes": ["security"], + "tags": ["identity"] }, { "type": "knowledge", @@ -6877,8 +6908,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["monitoring", "llm", "data-analytics"], - "integrationTypes": ["developer-tools", "ai", "analytics"] + "integrationTypes": ["developer-tools", "ai", "analytics"], + "tags": ["monitoring", "llm", "data-analytics"] }, { "type": "launchdarkly", @@ -6944,8 +6975,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["feature-flags", "ci-cd"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["feature-flags", "ci-cd"] }, { "type": "lemlist", @@ -7021,8 +7052,8 @@ "triggerCount": 9, "authType": "api-key", "category": "tools", - "tags": ["sales-engagement", "email-marketing", "automation"], - "integrationTypes": ["email", "sales"] + "integrationTypes": ["email", "developer-tools", "sales"], + "tags": ["sales-engagement", "email-marketing", "automation"] }, { "type": "linear_v2", @@ -7428,8 +7459,8 @@ "triggerCount": 15, "authType": "oauth", "category": "tools", - "tags": ["project-management", "ticketing"], - "integrationTypes": ["productivity", "developer-tools"] + "integrationTypes": ["productivity", "customer-support"], + "tags": ["project-management", "ticketing"] }, { "type": "linkedin", @@ -7455,8 +7486,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["marketing", "sales-engagement", "enrichment"], - "integrationTypes": ["communication", "analytics", "sales"] + "integrationTypes": ["sales", "analytics"], + "tags": ["marketing", "sales-engagement", "enrichment"] }, { "type": "linkup", @@ -7473,8 +7504,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "enrichment"], - "integrationTypes": ["search", "sales"] + "integrationTypes": ["search", "sales"], + "tags": ["web-scraping", "enrichment"] }, { "type": "loops", @@ -7532,8 +7563,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["email-marketing", "marketing", "automation"], - "integrationTypes": ["email", "analytics"] + "integrationTypes": ["email", "analytics", "developer-tools"], + "tags": ["email-marketing", "marketing", "automation"] }, { "type": "luma", @@ -7575,8 +7606,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["events", "calendar", "scheduling"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity"], + "tags": ["events", "calendar", "scheduling"] }, { "type": "mailchimp", @@ -7886,8 +7917,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["email-marketing", "marketing", "automation"], - "integrationTypes": ["email", "analytics"] + "integrationTypes": ["email", "analytics", "developer-tools"], + "tags": ["email-marketing", "marketing", "automation"] }, { "type": "mailgun", @@ -7937,8 +7968,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["messaging", "email-marketing"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["messaging", "email-marketing"] }, { "type": "mem0", @@ -7968,8 +7999,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "knowledge-base", "agentic"], - "integrationTypes": ["ai", "documents", "search"] + "integrationTypes": ["ai", "documents", "search"], + "tags": ["llm", "knowledge-base", "agentic"] }, { "type": "memory", @@ -8088,8 +8119,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["microsoft-365", "data-warehouse", "cloud"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["microsoft-365", "data-warehouse", "cloud"] }, { "type": "microsoft_excel_v2", @@ -8115,8 +8146,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["spreadsheet", "microsoft-365"], - "integrationTypes": ["documents", "databases", "productivity"] + "integrationTypes": ["documents", "databases", "productivity"], + "tags": ["spreadsheet", "microsoft-365"] }, { "type": "microsoft_planner", @@ -8186,8 +8217,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["project-management", "microsoft-365", "ticketing"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity", "customer-support"], + "tags": ["project-management", "microsoft-365", "ticketing"] }, { "type": "microsoft_teams", @@ -8267,8 +8298,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["messaging", "microsoft-365"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication"], + "tags": ["messaging", "microsoft-365"] }, { "type": "mistral_parse_v3", @@ -8285,8 +8316,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["document-processing", "ocr"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["document-processing", "ocr"] }, { "type": "mongodb", @@ -8328,8 +8359,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-warehouse", "cloud"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["data-warehouse", "cloud"] }, { "type": "mysql", @@ -8371,8 +8402,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-warehouse", "data-analytics"], - "integrationTypes": ["databases", "analytics"] + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "data-analytics"] }, { "type": "neo4j", @@ -8418,8 +8449,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-warehouse", "data-analytics"], - "integrationTypes": ["databases", "analytics"] + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "data-analytics"] }, { "type": "notion_v2", @@ -8482,8 +8513,8 @@ "triggerCount": 9, "authType": "oauth", "category": "tools", - "tags": ["note-taking", "knowledge-base", "content-management"], - "integrationTypes": ["documents", "productivity", "search"] + "integrationTypes": ["documents", "productivity", "search"], + "tags": ["note-taking", "knowledge-base", "content-management"] }, { "type": "obsidian", @@ -8561,8 +8592,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["note-taking", "knowledge-base"], - "integrationTypes": ["documents", "productivity", "search"] + "integrationTypes": ["documents", "productivity", "search"], + "tags": ["note-taking", "knowledge-base"] }, { "type": "okta", @@ -8652,8 +8683,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["identity", "automation"], - "integrationTypes": ["security"] + "integrationTypes": ["security", "developer-tools"], + "tags": ["identity", "automation"] }, { "type": "onedrive", @@ -8695,8 +8726,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["microsoft-365", "cloud", "document-processing"], - "integrationTypes": ["file-storage", "developer-tools", "documents"] + "integrationTypes": ["file-storage", "developer-tools", "documents"], + "tags": ["microsoft-365", "cloud", "document-processing"] }, { "type": "outlook", @@ -8756,8 +8787,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["microsoft-365", "messaging", "automation"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication", "developer-tools"], + "tags": ["microsoft-365", "messaging", "automation"] }, { "type": "pagerduty", @@ -8799,8 +8830,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["incident-management", "monitoring"], - "integrationTypes": ["developer-tools", "analytics"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["incident-management", "monitoring"] }, { "type": "parallel_ai", @@ -8830,8 +8861,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "llm", "agentic"], - "integrationTypes": ["search", "ai"] + "integrationTypes": ["search", "ai"], + "tags": ["web-scraping", "llm", "agentic"] }, { "type": "perplexity", @@ -8857,8 +8888,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "web-scraping", "agentic"], - "integrationTypes": ["ai", "search"] + "integrationTypes": ["ai", "search"], + "tags": ["llm", "web-scraping", "agentic"] }, { "type": "pinecone", @@ -8896,8 +8927,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["vector-search", "knowledge-base"], - "integrationTypes": ["databases", "ai", "documents", "search"] + "integrationTypes": ["databases", "ai", "documents", "search"], + "tags": ["vector-search", "knowledge-base"] }, { "type": "pipedrive", @@ -8987,8 +9018,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["sales-engagement", "project-management"], - "integrationTypes": ["crm", "productivity", "sales"] + "integrationTypes": ["crm", "productivity", "sales"], + "tags": ["sales-engagement", "project-management"] }, { "type": "polymarket", @@ -9086,8 +9117,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["prediction-markets", "data-analytics"], - "integrationTypes": ["analytics"] + "integrationTypes": ["analytics"], + "tags": ["prediction-markets", "data-analytics"] }, { "type": "postgresql", @@ -9129,8 +9160,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-warehouse", "data-analytics"], - "integrationTypes": ["databases", "analytics"] + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "data-analytics"] }, { "type": "posthog", @@ -9320,8 +9351,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-analytics", "monitoring"], - "integrationTypes": ["analytics", "developer-tools"] + "integrationTypes": ["analytics", "developer-tools"], + "tags": ["data-analytics", "monitoring"] }, { "type": "profound", @@ -9435,8 +9466,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["seo", "data-analytics"], - "integrationTypes": ["analytics", "search"] + "integrationTypes": ["analytics", "search"], + "tags": ["seo", "data-analytics"] }, { "type": "pulse_v2", @@ -9453,8 +9484,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["document-processing", "ocr"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["document-processing", "ocr"] }, { "type": "qdrant", @@ -9484,8 +9515,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["vector-search", "knowledge-base"], - "integrationTypes": ["databases", "ai", "documents", "search"] + "integrationTypes": ["databases", "ai", "documents", "search"], + "tags": ["vector-search", "knowledge-base"] }, { "type": "quiver", @@ -9515,8 +9546,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["image-generation"], - "integrationTypes": ["design", "ai"] + "integrationTypes": ["design", "ai"], + "tags": ["image-generation"] }, { "type": "reddit", @@ -9602,8 +9633,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["content-management", "web-scraping"], - "integrationTypes": ["communication", "documents", "search"] + "integrationTypes": ["communication", "documents", "search"], + "tags": ["content-management", "web-scraping"] }, { "type": "redis", @@ -9709,8 +9740,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-warehouse"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["cloud", "data-warehouse"] }, { "type": "reducto_v2", @@ -9727,8 +9758,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["document-processing", "ocr"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["document-processing", "ocr"] }, { "type": "resend", @@ -9819,8 +9850,8 @@ "triggerCount": 8, "authType": "none", "category": "tools", - "tags": ["email-marketing", "messaging"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["email-marketing", "messaging"] }, { "type": "revenuecat", @@ -9878,8 +9909,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["payments", "subscriptions"], - "integrationTypes": ["ecommerce"] + "integrationTypes": ["ecommerce"], + "tags": ["payments", "subscriptions"] }, { "type": "rippling", @@ -10241,8 +10272,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["hiring"], - "integrationTypes": ["hr"] + "integrationTypes": ["hr"], + "tags": ["hiring"] }, { "type": "rootly", @@ -10368,8 +10399,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["incident-management", "monitoring"], - "integrationTypes": ["developer-tools", "analytics"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["incident-management", "monitoring"] }, { "type": "s3", @@ -10407,8 +10438,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-warehouse"], - "integrationTypes": ["file-storage", "databases", "developer-tools"] + "integrationTypes": ["file-storage", "databases", "developer-tools"], + "tags": ["cloud", "data-warehouse"] }, { "type": "salesforce", @@ -10597,8 +10628,8 @@ "triggerCount": 6, "authType": "oauth", "category": "tools", - "tags": ["sales-engagement", "customer-support"], - "integrationTypes": ["crm", "customer-support", "sales"] + "integrationTypes": ["crm", "customer-support", "sales"], + "tags": ["sales-engagement", "customer-support"] }, { "type": "search", @@ -10615,8 +10646,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["web-scraping", "seo"], - "integrationTypes": ["search", "analytics"] + "integrationTypes": ["search", "analytics"], + "tags": ["web-scraping", "seo"] }, { "type": "sendgrid", @@ -10698,8 +10729,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["email-marketing", "messaging"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["email-marketing", "messaging"] }, { "type": "sentry", @@ -10765,8 +10796,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["error-tracking", "monitoring"], - "integrationTypes": ["developer-tools", "analytics"] + "integrationTypes": ["developer-tools", "analytics"], + "tags": ["error-tracking", "monitoring"] }, { "type": "serper", @@ -10783,8 +10814,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "seo"], - "integrationTypes": ["search", "analytics"] + "integrationTypes": ["search", "analytics"], + "tags": ["web-scraping", "seo"] }, { "type": "servicenow", @@ -10844,8 +10875,8 @@ "triggerCount": 5, "authType": "none", "category": "tools", - "tags": ["customer-support", "ticketing", "incident-management"], - "integrationTypes": ["customer-support", "developer-tools", "productivity"] + "integrationTypes": ["customer-support", "developer-tools", "productivity"], + "tags": ["customer-support", "ticketing", "incident-management"] }, { "type": "sftp", @@ -10887,8 +10918,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "automation"], - "integrationTypes": ["file-storage", "developer-tools"] + "integrationTypes": ["file-storage", "developer-tools"], + "tags": ["cloud", "automation"] }, { "type": "sharepoint", @@ -10938,8 +10969,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["microsoft-365", "content-management", "document-processing"], - "integrationTypes": ["file-storage", "documents"] + "integrationTypes": ["documents"], + "tags": ["microsoft-365", "content-management", "document-processing"] }, { "type": "shopify", @@ -11041,8 +11072,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["payments", "subscriptions"], - "integrationTypes": ["ecommerce"] + "integrationTypes": ["ecommerce"], + "tags": ["payments", "subscriptions"] }, { "type": "similarweb", @@ -11080,8 +11111,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["marketing", "data-analytics", "seo"], - "integrationTypes": ["analytics", "search"] + "integrationTypes": ["analytics", "search"], + "tags": ["marketing", "data-analytics", "seo"] }, { "type": "sixtyfour", @@ -11115,8 +11146,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["enrichment", "sales-engagement"], - "integrationTypes": ["sales"] + "integrationTypes": ["sales"], + "tags": ["enrichment", "sales-engagement"] }, { "type": "slack", @@ -11240,8 +11271,8 @@ "triggerCount": 1, "authType": "oauth", "category": "tools", - "tags": ["messaging", "webhooks", "automation"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "webhooks", "automation"] }, { "type": "smtp", @@ -11258,8 +11289,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["email-marketing", "messaging"], - "integrationTypes": ["email", "communication"] + "integrationTypes": ["email", "communication"], + "tags": ["email-marketing", "messaging"] }, { "type": "stt_v2", @@ -11276,8 +11307,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["speech-to-text", "document-processing"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["speech-to-text", "document-processing"] }, { "type": "ssh", @@ -11347,8 +11378,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "automation"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["cloud", "automation"] }, { "type": "stagehand", @@ -11374,8 +11405,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "automation", "agentic"], - "integrationTypes": ["ai", "search"] + "integrationTypes": ["ai", "developer-tools", "search"], + "tags": ["web-scraping", "automation", "agentic"] }, { "type": "stripe", @@ -11599,8 +11630,8 @@ "triggerCount": 1, "authType": "api-key", "category": "tools", - "tags": ["payments", "subscriptions", "webhooks"], - "integrationTypes": ["ecommerce"] + "integrationTypes": ["ecommerce", "developer-tools"], + "tags": ["payments", "subscriptions", "webhooks"] }, { "type": "supabase", @@ -11706,8 +11737,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["cloud", "data-warehouse", "vector-search"], - "integrationTypes": ["databases", "ai", "developer-tools", "search"] + "integrationTypes": ["databases", "ai", "developer-tools", "search"], + "tags": ["cloud", "data-warehouse", "vector-search"] }, { "type": "tailscale", @@ -11805,8 +11836,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["monitoring"], - "integrationTypes": ["security", "developer-tools"] + "integrationTypes": ["security", "analytics", "developer-tools"], + "tags": ["monitoring"] }, { "type": "tavily", @@ -11840,8 +11871,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["web-scraping", "enrichment"], - "integrationTypes": ["search", "sales"] + "integrationTypes": ["search", "sales"], + "tags": ["web-scraping", "enrichment"] }, { "type": "telegram", @@ -11893,8 +11924,8 @@ "triggerCount": 1, "authType": "none", "category": "tools", - "tags": ["messaging", "webhooks", "automation"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "webhooks", "automation"] }, { "type": "tts", @@ -11911,8 +11942,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["text-to-speech", "llm"], - "integrationTypes": ["ai"] + "integrationTypes": ["ai"], + "tags": ["text-to-speech", "llm"] }, { "type": "tinybird", @@ -11938,8 +11969,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["data-warehouse", "data-analytics"], - "integrationTypes": ["analytics", "databases"] + "integrationTypes": ["analytics", "databases"], + "tags": ["data-warehouse", "data-analytics"] }, { "type": "translate", @@ -11956,15 +11987,15 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["document-processing", "llm"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["document-processing", "llm"] }, { "type": "trello", "slug": "trello", "name": "Trello", - "description": "Manage Trello boards and cards", - "longDescription": "Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments.", + "description": "Manage Trello lists, cards, and activity", + "longDescription": "Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.", "bgColor": "#0052CC", "iconName": "TrelloIcon", "docsUrl": "https://docs.sim.ai/tools/trello", @@ -11975,11 +12006,11 @@ }, { "name": "List Cards", - "description": "List all cards on a Trello board" + "description": "List cards from a Trello board or list" }, { "name": "Create Card", - "description": "Create a new card on a Trello board" + "description": "Create a new card in a Trello list" }, { "name": "Update Card", @@ -11999,8 +12030,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["project-management", "ticketing"], - "integrationTypes": ["productivity"] + "integrationTypes": ["productivity", "customer-support"], + "tags": ["project-management", "ticketing"] }, { "type": "twilio_sms", @@ -12017,8 +12048,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["messaging", "automation"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "automation"] }, { "type": "twilio_voice", @@ -12054,8 +12085,8 @@ "triggerCount": 1, "authType": "none", "category": "tools", - "tags": ["messaging", "text-to-speech"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "ai"], + "tags": ["messaging", "text-to-speech"] }, { "type": "typeform", @@ -12111,8 +12142,8 @@ "triggerCount": 1, "authType": "api-key", "category": "tools", - "tags": ["forms", "data-analytics"], - "integrationTypes": ["documents", "analytics", "productivity"] + "integrationTypes": ["documents", "analytics", "productivity"], + "tags": ["forms", "data-analytics"] }, { "type": "upstash", @@ -12194,8 +12225,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["cloud", "data-warehouse"], - "integrationTypes": ["databases", "developer-tools"] + "integrationTypes": ["databases", "developer-tools"], + "tags": ["cloud", "data-warehouse"] }, { "type": "vercel", @@ -12454,8 +12485,8 @@ "triggerCount": 8, "authType": "api-key", "category": "tools", - "tags": ["cloud", "ci-cd"], - "integrationTypes": ["developer-tools"] + "integrationTypes": ["developer-tools"], + "tags": ["cloud", "ci-cd"] }, { "type": "video_generator_v2", @@ -12472,8 +12503,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["video-generation", "llm"], - "integrationTypes": ["ai", "design"] + "integrationTypes": ["ai", "design"], + "tags": ["video-generation", "llm"] }, { "type": "vision_v2", @@ -12490,8 +12521,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "document-processing", "ocr"], - "integrationTypes": ["ai", "documents"] + "integrationTypes": ["ai", "documents"], + "tags": ["llm", "document-processing", "ocr"] }, { "type": "wealthbox", @@ -12533,8 +12564,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["sales-engagement", "customer-support"], - "integrationTypes": ["crm", "customer-support", "sales"] + "integrationTypes": ["crm", "customer-support", "sales"], + "tags": ["sales-engagement", "customer-support"] }, { "type": "webflow", @@ -12593,8 +12624,8 @@ "triggerCount": 4, "authType": "oauth", "category": "tools", - "tags": ["content-management", "seo"], - "integrationTypes": ["design", "analytics", "documents", "search"] + "integrationTypes": ["design", "analytics", "documents", "search"], + "tags": ["content-management", "seo"] }, { "type": "whatsapp", @@ -12611,14 +12642,14 @@ { "id": "whatsapp_webhook", "name": "WhatsApp Webhook", - "description": "Trigger workflow from WhatsApp messages and events via Business Platform webhooks" + "description": "Trigger workflow from WhatsApp incoming messages and message status webhooks" } ], "triggerCount": 1, "authType": "api-key", "category": "tools", - "tags": ["messaging", "automation"], - "integrationTypes": ["communication"] + "integrationTypes": ["communication", "developer-tools"], + "tags": ["messaging", "automation"] }, { "type": "wikipedia", @@ -12652,8 +12683,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["knowledge-base", "web-scraping"], - "integrationTypes": ["search", "documents"] + "integrationTypes": ["search", "documents"], + "tags": ["knowledge-base", "web-scraping"] }, { "type": "wordpress", @@ -12775,8 +12806,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["content-management", "seo"], - "integrationTypes": ["design", "analytics", "documents", "search"] + "integrationTypes": ["design", "analytics", "documents", "search"], + "tags": ["content-management", "seo"] }, { "type": "workday", @@ -12834,8 +12865,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["hiring", "project-management"], - "integrationTypes": ["hr", "productivity"] + "integrationTypes": ["hr", "productivity"], + "tags": ["hiring", "project-management"] }, { "type": "x", @@ -12965,8 +12996,8 @@ "triggerCount": 0, "authType": "oauth", "category": "tools", - "tags": ["marketing", "messaging"], - "integrationTypes": ["communication", "analytics"] + "integrationTypes": ["communication", "analytics"], + "tags": ["marketing", "messaging"] }, { "type": "youtube", @@ -13020,8 +13051,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["google-workspace", "marketing", "content-management"], - "integrationTypes": ["communication", "analytics"] + "integrationTypes": ["communication", "analytics", "documents"], + "tags": ["google-workspace", "marketing", "content-management"] }, { "type": "zendesk", @@ -13143,8 +13174,8 @@ "triggerCount": 0, "authType": "none", "category": "tools", - "tags": ["customer-support", "ticketing"], - "integrationTypes": ["customer-support", "productivity"] + "integrationTypes": ["customer-support", "productivity"], + "tags": ["customer-support", "ticketing"] }, { "type": "zep", @@ -13198,8 +13229,8 @@ "triggerCount": 0, "authType": "api-key", "category": "tools", - "tags": ["llm", "knowledge-base", "agentic"], - "integrationTypes": ["ai", "documents", "search"] + "integrationTypes": ["ai", "documents", "search"], + "tags": ["llm", "knowledge-base", "agentic"] }, { "type": "zoom", @@ -13288,7 +13319,7 @@ "triggerCount": 6, "authType": "oauth", "category": "tools", - "tags": ["meeting", "calendar", "scheduling"], - "integrationTypes": ["communication", "productivity"] + "integrationTypes": ["communication", "productivity"], + "tags": ["meeting", "calendar", "scheduling"] } ] diff --git a/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx b/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx index a86095f3e47..dfa7e7edcb8 100644 --- a/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx +++ b/apps/sim/app/(landing)/models/components/model-comparison-charts.tsx @@ -59,7 +59,7 @@ function ModelLabel({ model }: ModelLabelProps) { const Icon = PROVIDER_ICON_MAP[model.providerId] return ( -
+
{Icon && } {model.displayName} @@ -116,7 +116,7 @@ function StackedCostChart({ models }: ChartProps) {
- + {formatPrice(input)} input / {formatPrice(output)} output
@@ -196,7 +196,7 @@ function ContextWindowChart({ models }: ChartProps) { opacity: 0.8, }} /> - + {formatTokenCount(value)}
diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 0607f7c8cfb..ed5a58cb3ce 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -4,19 +4,13 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') export const dynamic = 'force-dynamic' -const SHOPIFY_SCOPES = [ - 'write_products', - 'write_orders', - 'write_customers', - 'write_inventory', - 'read_locations', - 'write_merchant_managed_fulfillment_orders', -].join(',') +const SHOPIFY_SCOPES = getScopesForService('shopify').join(',') export async function GET(request: NextRequest) { try { diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index d5e23abf03a..e1945b1febf 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -1,14 +1,15 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function GET() { try { const session = await getSession() if (!session?.user?.id) { @@ -24,13 +25,15 @@ export async function GET(request: NextRequest) { const baseUrl = getBaseUrl() const returnUrl = `${baseUrl}/api/auth/trello/callback` + const scope = getCanonicalScopesForProvider('trello').join(',') const authUrl = new URL('https://trello.com/1/authorize') authUrl.searchParams.set('key', apiKey) authUrl.searchParams.set('name', 'Sim Studio') authUrl.searchParams.set('expiration', 'never') + authUrl.searchParams.set('callback_method', 'fragment') authUrl.searchParams.set('response_type', 'token') - authUrl.searchParams.set('scope', 'read,write') + authUrl.searchParams.set('scope', scope) authUrl.searchParams.set('return_url', returnUrl) return NextResponse.redirect(authUrl.toString()) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2aa76dc8ad6..a70da8fadc7 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -1,9 +1,9 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/core/utils/urls' export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function GET() { const baseUrl = getBaseUrl() return new NextResponse( @@ -75,6 +75,11 @@ export async function GET(request: NextRequest) { const fragment = window.location.hash.substring(1); const params = new URLSearchParams(fragment); const token = params.get('token'); + const authError = params.get('error'); + + if (authError) { + throw new Error(authError); + } if (!token) { throw new Error('No token received from Trello'); diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 47e59766a4b..8e2f96aa8ba 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' const logger = createLogger('TrelloStore') @@ -20,8 +21,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { token } = body + const body = (await request.json().catch(() => null)) as { token?: string } | null + const token = typeof body?.token === 'string' ? body.token : '' if (!token) { return NextResponse.json({ success: false, error: 'Token required' }, { status: 400 }) @@ -33,7 +34,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Trello not configured' }, { status: 500 }) } - const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName,email` + const scope = getCanonicalScopesForProvider('trello').join(',') + + const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName` const userResponse = await fetch(validationUrl, { headers: { Accept: 'application/json' }, }) @@ -50,7 +53,17 @@ export async function POST(request: NextRequest) { ) } - const trelloUser = await userResponse.json() + const trelloUser = (await userResponse.json().catch(() => null)) as { id?: string } | null + + if (typeof trelloUser?.id !== 'string' || trelloUser.id.trim().length === 0) { + logger.error('Trello validation response did not include a valid member id', { + response: trelloUser, + }) + return NextResponse.json( + { success: false, error: 'Invalid Trello member response' }, + { status: 502 } + ) + } const existing = await db.query.account.findFirst({ where: and( @@ -68,7 +81,7 @@ export async function POST(request: NextRequest) { .set({ accessToken: token, accountId: trelloUser.id, - scope: 'read,write', + scope, updatedAt: now, }) .where(eq(account.id, existing.id)) @@ -80,7 +93,7 @@ export async function POST(request: NextRequest) { providerId: 'trello', accountId: trelloUser.id, accessToken: token, - scope: 'read,write', + scope, createdAt: now, updatedAt: now, }, diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts new file mode 100644 index 00000000000..3e0b88d02de --- /dev/null +++ b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock, mockCheckInternalAuth } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + mockCheckInternalAuth: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkInternalAuth: mockCheckInternalAuth, +})) + +import { POST } from '@/app/api/tools/crowdstrike/query/route' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +const sensorResource = { + agent_version: '6.1.0', + cid: 'cid-1', + device_id: 'sensor-1', + heartbeat_time: 1700, + hostname: 'host-1', + idp_policy_id: 'policy-1', + idp_policy_name: 'Default Policy', + kerberos_config: 'configured', + ldap_config: 'configured', + ldaps_config: 'configured', + local_ip: '10.0.0.1', + machine_domain: 'corp.local', + ntlm_config: 'configured', + os_version: 'Windows Server 2022', + rdp_to_dc_config: 'configured', + smb_to_dc_config: 'configured', + status: 'protected', + status_causes: ['healthy'], + ti_enabled: 'enabled', +} + +const normalizedSensor = { + agentVersion: '6.1.0', + cid: 'cid-1', + deviceId: 'sensor-1', + heartbeatTime: 1700, + hostname: 'host-1', + idpPolicyId: 'policy-1', + idpPolicyName: 'Default Policy', + ipAddress: '10.0.0.1', + kerberosConfig: 'configured', + ldapConfig: 'configured', + ldapsConfig: 'configured', + machineDomain: 'corp.local', + ntlmConfig: 'configured', + osVersion: 'Windows Server 2022', + rdpToDcConfig: 'configured', + smbToDcConfig: 'configured', + status: 'protected', + statusCauses: ['healthy'], + tiEnabled: 'enabled', +} + +describe('CrowdStrike query route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + + mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + }) + + it('hydrates sensor details after querying sensor ids', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + meta: { pagination: { expires_at: 111, limit: 1, offset: 0, total: 1 } }, + resources: ['sensor-1'], + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + resources: [sensorResource], + }) + ) + + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/queries/devices/v1?limit=1' + ) + expect(fetchMock.mock.calls[2]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/entities/devices/GET/v1' + ) + expect(fetchMock.mock.calls[2]?.[1]).toMatchObject({ + body: JSON.stringify({ ids: ['sensor-1'] }), + method: 'POST', + }) + expect(data.output).toEqual({ + count: 1, + pagination: { + limit: 1, + offset: 0, + total: 1, + }, + sensors: [normalizedSensor], + }) + }) + + it('fetches sensor details directly from device ids', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + resources: [sensorResource], + }) + ) + + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + ids: ['sensor-1'], + operation: 'crowdstrike_get_sensor_details', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/entities/devices/GET/v1' + ) + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify({ ids: ['sensor-1'] }), + method: 'POST', + }) + expect(data.output).toEqual({ + count: 1, + pagination: null, + sensors: [normalizedSensor], + }) + }) + + it('normalizes sensor aggregate results', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + resources: [ + { + buckets: [ + { + count: 2, + key_as_string: 'protected', + sub_aggregates: [ + { + buckets: [ + { + count: 2, + key_as_string: 'corp.local', + value: 2, + value_as_string: '2', + }, + ], + doc_count_error_upper_bound: 0, + name: 'machine_domain_counts', + sum_other_doc_count: 0, + }, + ], + value: 2, + value_as_string: '2', + }, + ], + doc_count_error_upper_bound: 0, + name: 'status_counts', + sum_other_doc_count: 0, + }, + ], + }) + ) + + const aggregateQuery = { + field: 'status', + name: 'status_counts', + size: 10, + type: 'terms', + } + + const request = createMockRequest('POST', { + aggregateQuery, + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + operation: 'crowdstrike_get_sensor_aggregates', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/aggregates/devices/GET/v1' + ) + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify(aggregateQuery), + method: 'POST', + }) + expect(data.output).toEqual({ + aggregates: [ + { + buckets: [ + { + count: 2, + from: null, + keyAsString: 'protected', + label: null, + stringFrom: null, + stringTo: null, + subAggregates: [ + { + buckets: [ + { + count: 2, + from: null, + keyAsString: 'corp.local', + label: null, + stringFrom: null, + stringTo: null, + subAggregates: [], + to: null, + value: 2, + valueAsString: '2', + }, + ], + docCountErrorUpperBound: 0, + name: 'machine_domain_counts', + sumOtherDocCount: 0, + }, + ], + to: null, + value: 2, + valueAsString: '2', + }, + ], + docCountErrorUpperBound: 0, + name: 'status_counts', + sumOtherDocCount: 0, + }, + ], + count: 1, + }) + }) +}) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts new file mode 100644 index 00000000000..63ad81e2899 --- /dev/null +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -0,0 +1,485 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateId } from '@/lib/core/utils/uuid' +import type { + CrowdStrikeAggregateQuery, + CrowdStrikeCloud, + CrowdStrikeSensorAggregateBucket, + CrowdStrikeSensorAggregateResult, +} from '@/tools/crowdstrike/types' + +const logger = createLogger('CrowdStrikeIdentityProtectionAPI') + +const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'eu-1', 'us-gov-1', 'us-gov-2'] as const + +type JsonRecord = Record + +const BaseRequestSchema = z.object({ + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client Secret is required'), + cloud: z.enum(CROWDSTRIKE_CLOUDS), +}) + +const DateRangeSchema = z.object({ + from: z.string(), + to: z.string(), +}) + +const ExtendedBoundsSchema = z.object({ + max: z.string(), + min: z.string(), +}) + +const RangeSpecSchema = z.object({ + from: z.number(), + to: z.number(), +}) + +const AggregateQuerySchema: z.ZodType = z.lazy(() => + z.object({ + date_ranges: z.array(DateRangeSchema).optional(), + exclude: z.string().optional(), + extended_bounds: ExtendedBoundsSchema.optional(), + field: z.string().optional(), + filter: z.string().optional(), + from: z.number().int().nonnegative().optional(), + include: z.string().optional(), + interval: z.string().optional(), + max_doc_count: z.number().int().nonnegative().optional(), + min_doc_count: z.number().int().nonnegative().optional(), + missing: z.string().optional(), + name: z.string().optional(), + q: z.string().optional(), + ranges: z.array(RangeSpecSchema).optional(), + size: z.number().int().nonnegative().optional(), + sort: z.string().optional(), + sub_aggregates: z.array(AggregateQuerySchema).optional(), + time_zone: z.string().optional(), + type: z.string().optional(), + }) +) + +const QuerySensorsSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_sensors'), + filter: z.string().optional(), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(200, 'Limit must be at most 200') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + sort: z.string().optional(), +}) + +const GetSensorDetailsSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_sensor_details'), + ids: z + .array(z.string().trim().min(1, 'Sensor IDs must not be empty')) + .min(1, 'At least one sensor ID is required') + .max(5000, 'CrowdStrike supports up to 5000 sensor IDs per request'), +}) + +const GetSensorAggregatesSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_sensor_aggregates'), + aggregateQuery: AggregateQuerySchema, +}) + +const RequestSchema = z.discriminatedUnion('operation', [ + QuerySensorsSchema, + GetSensorDetailsSchema, + GetSensorAggregatesSchema, +]) + +type CrowdStrikeAuthRequest = z.infer +type CrowdStrikeQuerySensorsRequest = z.infer + +function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { + const cloudMap: Record = { + 'eu-1': 'https://api.eu-1.crowdstrike.com', + 'us-1': 'https://api.crowdstrike.com', + 'us-2': 'https://api.us-2.crowdstrike.com', + 'us-gov-1': 'https://api.laggar.gcw.crowdstrike.com', + 'us-gov-2': 'https://api.us-gov-2.crowdstrike.mil', + } + + return cloudMap[cloud] +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function getNumber(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value.filter((entry): entry is string => typeof entry === 'string') +} + +function getRecordArray(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) { + return [] + } + + return value.filter(isJsonRecord) +} + +function getResourcesArray(data: unknown): unknown[] { + const root = getResponseRoot(data) + if (!isJsonRecord(root) || !Array.isArray(root.resources)) { + return [] + } + + return root.resources +} + +function getRecordResources(data: unknown): JsonRecord[] { + return getResourcesArray(data).filter(isJsonRecord) +} + +function getStringResources(data: unknown): string[] { + return getStringArray(getResourcesArray(data)) +} + +function getResponseRoot(data: unknown): unknown { + if (!isJsonRecord(data)) { + return null + } + + if (isJsonRecord(data.body)) { + return data.body + } + + return data +} + +function getPagination(data: unknown) { + const root = getResponseRoot(data) + if (!isJsonRecord(root) || !isJsonRecord(root.meta) || !isJsonRecord(root.meta.pagination)) { + return null + } + + return { + limit: getNumber(root.meta.pagination.limit), + offset: getNumber(root.meta.pagination.offset), + total: getNumber(root.meta.pagination.total), + } +} + +function getErrorMessage(data: unknown, fallback: string): string { + if (!isJsonRecord(data)) { + return fallback + } + + const errors = Array.isArray(data.errors) ? data.errors : [] + const firstError = errors[0] + if (isJsonRecord(firstError)) { + const firstMessage = getString(firstError.message) ?? getString(firstError.code) + if (firstMessage) { + return firstMessage + } + } + + return ( + getString(data.message) ?? + getString(data.error_description) ?? + getString(data.error) ?? + fallback + ) +} + +function buildQueryUrl(baseUrl: string, params: CrowdStrikeQuerySensorsRequest): string { + const url = new URL(baseUrl) + url.pathname = '/identity-protection/queries/devices/v1' + + if (params.filter) { + url.searchParams.set('filter', params.filter) + } + + if (params.limit != null) { + url.searchParams.set('limit', params.limit.toString()) + } + + if (params.offset != null) { + url.searchParams.set('offset', params.offset.toString()) + } + + if (params.sort) { + url.searchParams.set('sort', params.sort) + } + + return url.toString() +} + +function buildSensorDetailsUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/identity-protection/entities/devices/GET/v1' + return url.toString() +} + +function buildSensorAggregatesUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/identity-protection/aggregates/devices/GET/v1' + return url.toString() +} + +async function getAccessToken(params: CrowdStrikeAuthRequest): Promise { + const baseUrl = getCloudBaseUrl(params.cloud) + const response = await fetch(`${baseUrl}/oauth2/token`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: params.clientId, + client_secret: params.clientSecret, + grant_type: 'client_credentials', + }).toString(), + cache: 'no-store', + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(getErrorMessage(data, 'Failed to authenticate with CrowdStrike')) + } + + if (!isJsonRecord(data) || typeof data.access_token !== 'string') { + throw new Error('CrowdStrike authentication did not return an access token') + } + + return data.access_token +} + +function normalizeSensor(resource: JsonRecord) { + return { + agentVersion: getString(resource.agent_version), + cid: getString(resource.cid), + deviceId: getString(resource.device_id), + heartbeatTime: getNumber(resource.heartbeat_time), + hostname: getString(resource.hostname), + idpPolicyId: getString(resource.idp_policy_id), + idpPolicyName: getString(resource.idp_policy_name), + ipAddress: getString(resource.local_ip), + kerberosConfig: getString(resource.kerberos_config), + ldapConfig: getString(resource.ldap_config), + ldapsConfig: getString(resource.ldaps_config), + machineDomain: getString(resource.machine_domain), + ntlmConfig: getString(resource.ntlm_config), + osVersion: getString(resource.os_version), + rdpToDcConfig: getString(resource.rdp_to_dc_config), + smbToDcConfig: getString(resource.smb_to_dc_config), + status: getString(resource.status), + statusCauses: getStringArray(resource.status_causes), + tiEnabled: getString(resource.ti_enabled), + } +} + +function normalizeSensorsOutput(data: unknown, paginationData?: unknown) { + const sensors = getRecordResources(data).map(normalizeSensor) + + return { + count: sensors.length, + pagination: paginationData == null ? null : getPagination(paginationData), + sensors, + } +} + +function normalizeAggregationResult(resource: JsonRecord): CrowdStrikeSensorAggregateResult { + return { + buckets: getRecordArray(resource.buckets).map(normalizeAggregationBucket), + docCountErrorUpperBound: getNumber(resource.doc_count_error_upper_bound), + name: getString(resource.name), + sumOtherDocCount: getNumber(resource.sum_other_doc_count), + } +} + +function normalizeAggregationBucket(resource: JsonRecord): CrowdStrikeSensorAggregateBucket { + return { + count: getNumber(resource.count), + from: getNumber(resource.from), + keyAsString: getString(resource.key_as_string), + label: isJsonRecord(resource.label) ? resource.label : null, + stringFrom: getString(resource.string_from), + stringTo: getString(resource.string_to), + subAggregates: getRecordArray(resource.sub_aggregates).map(normalizeAggregationResult), + to: getNumber(resource.to), + value: getNumber(resource.value), + valueAsString: getString(resource.value_as_string), + } +} + +function normalizeAggregatesOutput(data: unknown) { + const aggregates = getRecordResources(data).map(normalizeAggregationResult) + + return { + aggregates, + count: aggregates.length, + } +} + +async function postCrowdStrikeJson( + url: string, + accessToken: string, + body: JsonRecord | CrowdStrikeAggregateQuery +) { + return fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }) +} + +export async function POST(request: NextRequest) { + const requestId = generateId().slice(0, 8) + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const rawBody: unknown = await request.json() + const params = RequestSchema.parse(rawBody) + const baseUrl = getCloudBaseUrl(params.cloud) + const accessToken = await getAccessToken(params) + + logger.info(`[${requestId}] CrowdStrike request`, { + cloud: params.cloud, + operation: params.operation, + }) + + if (params.operation === 'crowdstrike_query_sensors') { + const queryResponse = await fetch(buildQueryUrl(baseUrl, params), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const queryData: unknown = await queryResponse.json().catch(() => null) + if (!queryResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(queryData, 'CrowdStrike request failed'), + }, + { status: queryResponse.status } + ) + } + + const ids = getStringResources(queryData) + if (ids.length === 0) { + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput({ resources: [] }, queryData), + }) + } + + const detailResponse = await postCrowdStrikeJson( + buildSensorDetailsUrl(baseUrl), + accessToken, + { ids } + ) + + const detailData: unknown = await detailResponse.json().catch(() => null) + if (!detailResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(detailData, 'Failed to fetch CrowdStrike sensor details'), + }, + { status: detailResponse.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput(detailData, queryData), + }) + } + + if (params.operation === 'crowdstrike_get_sensor_details') { + const detailResponse = await postCrowdStrikeJson( + buildSensorDetailsUrl(baseUrl), + accessToken, + { ids: params.ids } + ) + + const detailData: unknown = await detailResponse.json().catch(() => null) + if (!detailResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(detailData, 'Failed to fetch CrowdStrike sensor details'), + }, + { status: detailResponse.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput(detailData), + }) + } + + const aggregateResponse = await postCrowdStrikeJson( + buildSensorAggregatesUrl(baseUrl), + accessToken, + params.aggregateQuery + ) + + const aggregateData: unknown = await aggregateResponse.json().catch(() => null) + if (!aggregateResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(aggregateData, 'Failed to fetch CrowdStrike sensor aggregates'), + }, + { status: aggregateResponse.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeAggregatesOutput(aggregateData), + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { + success: false, + error: error.errors[0]?.message ?? 'Invalid request data', + details: error.errors, + }, + { status: 400 } + ) + } + + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] CrowdStrike request failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts index fb4ca52738a..3b3851285a6 100644 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -8,7 +8,7 @@ const logger = createLogger('TrelloBoardsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export async function POST(request: NextRequest) { const requestId = generateRequestId() try { const apiKey = process.env.TRELLO_API_KEY @@ -16,15 +16,19 @@ export async function POST(request: Request) { logger.error('Trello API key not configured') return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) } - const body = await request.json() - const { credential, workflowId } = body + const body = (await request.json().catch(() => null)) as { + credential?: string + workflowId?: string + } | null + const credential = typeof body?.credential === 'string' ? body.credential : '' + const workflowId = typeof body?.workflowId === 'string' ? body.workflowId : undefined if (!credential) { logger.error('Missing credential in request') return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -58,7 +62,7 @@ export async function POST(request: Request) { ) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = await response.json().catch(() => null) logger.error('Failed to fetch Trello boards', { status: response.status, error: errorData, @@ -69,12 +73,31 @@ export async function POST(request: Request) { ) } - const data = await response.json() - const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({ - id: board.id, - name: board.name, - closed: board.closed, - })) + const data = (await response.json().catch(() => null)) as unknown + + if (!Array.isArray(data)) { + logger.error('Trello returned an invalid board collection', { data }) + return NextResponse.json({ error: 'Invalid Trello board response' }, { status: 502 }) + } + + const boards = data.flatMap((board) => { + if (typeof board !== 'object' || board === null) { + return [] + } + + const record = board as Record + if (typeof record.id !== 'string' || typeof record.name !== 'string') { + return [] + } + + return [ + { + id: record.id, + name: record.name, + closed: typeof record.closed === 'boolean' ? record.closed : false, + }, + ] + }) return NextResponse.json({ boards }) } catch (error) { diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts new file mode 100644 index 00000000000..b2433e924d5 --- /dev/null +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -0,0 +1,212 @@ +import { CrowdStrikeIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { parseOptionalJsonInput, parseOptionalNumberInput } from '@/blocks/utils' +import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types' + +export const CrowdStrikeBlock: BlockConfig = { + type: 'crowdstrike', + name: 'CrowdStrike', + description: 'Query CrowdStrike Identity Protection sensors and documented aggregates', + longDescription: + 'Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.', + docsLink: 'https://docs.sim.ai/tools/crowdstrike', + category: 'tools', + integrationType: IntegrationType.Security, + tags: ['identity', 'monitoring'], + bgColor: '#E01F3D', + icon: CrowdStrikeIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query Sensors', id: 'crowdstrike_query_sensors' }, + { label: 'Get Sensor Details', id: 'crowdstrike_get_sensor_details' }, + { label: 'Get Sensor Aggregates', id: 'crowdstrike_get_sensor_aggregates' }, + ], + value: () => 'crowdstrike_query_sensors', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'CrowdStrike Falcon API client ID', + required: true, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + password: true, + placeholder: 'CrowdStrike Falcon API client secret', + required: true, + }, + { + id: 'cloud', + title: 'Cloud Region', + type: 'dropdown', + options: [ + { label: 'US-1', id: 'us-1' }, + { label: 'US-2', id: 'us-2' }, + { label: 'EU-1', id: 'eu-1' }, + { label: 'US-GOV-1', id: 'us-gov-1' }, + { label: 'US-GOV-2', id: 'us-gov-2' }, + ], + value: () => 'us-1', + required: true, + }, + { + id: 'filter', + title: 'Filter', + type: 'short-input', + placeholder: 'hostname:"server-01" or status:"protected"', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a CrowdStrike Identity Protection Falcon Query Language filter string for sensor search. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', + placeholder: + 'Describe the sensors you want to search, for example "sensors with hostnames starting with web" or "sensors with protected status"...', + }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + mode: 'advanced', + }, + { + id: 'sort', + title: 'Sort', + type: 'short-input', + placeholder: 'status.asc', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, + mode: 'advanced', + }, + { + id: 'ids', + title: 'Sensor IDs', + type: 'code', + language: 'json', + placeholder: '["device-id-1", "device-id-2"]', + condition: { field: 'operation', value: 'crowdstrike_get_sensor_details' }, + required: { field: 'operation', value: 'crowdstrike_get_sensor_details' }, + }, + { + id: 'aggregateQuery', + title: 'Aggregate Query', + type: 'code', + language: 'json', + placeholder: + '{\n "field": "field_name",\n "name": "aggregate_name",\n "size": 10,\n "type": "aggregate_type"\n}', + condition: { field: 'operation', value: 'crowdstrike_get_sensor_aggregates' }, + required: { field: 'operation', value: 'crowdstrike_get_sensor_aggregates' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a CrowdStrike Identity Protection sensor aggregate query JSON object using documented aggregate body fields such as field, filter, size, sort, type, date_ranges, ranges, extended_bounds, and sub_aggregates. Return ONLY valid JSON.', + placeholder: + 'Describe the aggregation you want to run, for example "count sensors by status"...', + generationType: 'json-object', + }, + }, + ], + + tools: { + access: [ + 'crowdstrike_get_sensor_aggregates', + 'crowdstrike_get_sensor_details', + 'crowdstrike_query_sensors', + ], + config: { + tool: (params) => + typeof params.operation === 'string' ? params.operation : 'crowdstrike_query_sensors', + params: (params) => { + const mapped: Record = { + clientId: params.clientId, + clientSecret: params.clientSecret, + cloud: params.cloud, + } + + if (params.operation === 'crowdstrike_query_sensors') { + if (params.filter) mapped.filter = params.filter + + const limit = parseOptionalNumberInput(params.limit, 'limit', { + integer: true, + max: 200, + min: 1, + }) + const offset = parseOptionalNumberInput(params.offset, 'offset', { + integer: true, + min: 0, + }) + + if (limit != null) mapped.limit = limit + if (offset != null) mapped.offset = offset + if (params.sort) mapped.sort = params.sort + } + + if (params.operation === 'crowdstrike_get_sensor_details') { + const ids = parseOptionalJsonInput(params.ids, 'sensor IDs') + if (ids !== undefined) mapped.ids = ids + } + + if (params.operation === 'crowdstrike_get_sensor_aggregates') { + const aggregateQuery = parseOptionalJsonInput(params.aggregateQuery, 'aggregate query') + if (aggregateQuery !== undefined) mapped.aggregateQuery = aggregateQuery + } + + return mapped + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Selected CrowdStrike operation' }, + clientId: { type: 'string', description: 'CrowdStrike Falcon API client ID' }, + clientSecret: { type: 'string', description: 'CrowdStrike Falcon API client secret' }, + cloud: { type: 'string', description: 'CrowdStrike Falcon cloud region' }, + filter: { type: 'string', description: 'Falcon Query Language filter' }, + ids: { type: 'json', description: 'JSON array of CrowdStrike sensor device IDs' }, + aggregateQuery: { + type: 'json', + description: 'CrowdStrike sensor aggregate query body as JSON', + }, + limit: { type: 'number', description: 'Maximum number of records to return' }, + offset: { type: 'number', description: 'Pagination offset' }, + sort: { type: 'string', description: 'Sort expression' }, + }, + + outputs: { + sensors: { + type: 'json', + description: + 'CrowdStrike identity sensor records (agentVersion, cid, deviceId, heartbeatTime, hostname, idpPolicyId, idpPolicyName, ipAddress, kerberosConfig, ldapConfig, ldapsConfig, machineDomain, ntlmConfig, osVersion, rdpToDcConfig, smbToDcConfig, status, statusCauses, tiEnabled)', + }, + aggregates: { + type: 'json', + description: + 'CrowdStrike aggregate result groups (name, buckets, docCountErrorUpperBound, sumOtherDocCount)', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total) for query responses', + }, + count: { type: 'number', description: 'Number of records returned by the selected operation' }, + }, +} diff --git a/apps/sim/blocks/blocks/shopify.ts b/apps/sim/blocks/blocks/shopify.ts index d21d414c8c5..d1c32bf8af7 100644 --- a/apps/sim/blocks/blocks/shopify.ts +++ b/apps/sim/blocks/blocks/shopify.ts @@ -2,6 +2,7 @@ import { ShopifyIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { parseOptionalBooleanInput, parseOptionalNumberInput } from '@/blocks/utils' interface ShopifyResponse { success: boolean @@ -9,6 +10,15 @@ interface ShopifyResponse { output: Record } +const LIST_OPERATIONS = [ + 'shopify_list_products', + 'shopify_list_orders', + 'shopify_list_customers', + 'shopify_list_inventory_items', + 'shopify_list_locations', + 'shopify_list_collections', +] as const + export const ShopifyBlock: BlockConfig = { type: 'shopify', name: 'Shopify', @@ -84,7 +94,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Shop Domain', type: 'short-input', placeholder: 'Auto-detected from OAuth or enter manually', - hidden: true, // Auto-detected from OAuth credential's idToken field + hidden: true, }, // Product ID (for get/update/delete operations) { @@ -179,6 +189,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'Filter products (optional)', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_products'], @@ -190,6 +201,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., first_name:John OR email:*@gmail.com', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_customers'], @@ -201,11 +213,23 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., sku:ABC123', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_inventory_items'], }, }, + { + id: 'first', + title: 'Max Results', + type: 'short-input', + placeholder: 'Defaults to 50, max 250', + mode: 'advanced', + condition: { + field: 'operation', + value: LIST_OPERATIONS as unknown as string[], + }, + }, // Order ID { id: 'orderId', @@ -235,6 +259,17 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_list_orders'], }, }, + { + id: 'orderQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'e.g., financial_status:paid OR email:customer@example.com', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_list_orders'], + }, + }, // Order Note (for update) { id: 'orderNote', @@ -278,6 +313,7 @@ export const ShopifyBlock: BlockConfig = { { label: 'Declined Payment', id: 'DECLINED' }, { label: 'Fraud', id: 'FRAUD' }, { label: 'Inventory Issue', id: 'INVENTORY' }, + { label: 'Staff Error', id: 'STAFF' }, { label: 'Other', id: 'OTHER' }, ], value: () => 'OTHER', @@ -298,6 +334,52 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_cancel_order'], }, }, + { + id: 'restock', + title: 'Restock Inventory', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + required: true, + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, + { + id: 'cancelNotifyCustomer', + title: 'Notify Customer', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, + { + id: 'refundOriginalPayment', + title: 'Refund to Original Payment Method', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, // Customer ID { id: 'customerId', @@ -376,16 +458,6 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_create_customer', 'shopify_update_customer'], }, }, - // Accepts Marketing - { - id: 'acceptsMarketing', - title: 'Accepts Marketing', - type: 'switch', - condition: { - field: 'operation', - value: ['shopify_create_customer', 'shopify_update_customer'], - }, - }, // Inventory Item ID { id: 'inventoryItemId', @@ -474,12 +546,33 @@ export const ShopifyBlock: BlockConfig = { { id: 'notifyCustomer', title: 'Notify Customer', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_create_fulfillment'], }, }, + { + id: 'includeInactive', + title: 'Include Inactive Locations', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_list_locations'], + }, + }, // Collection ID { id: 'collectionId', @@ -498,11 +591,23 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., title:Summer OR collection_type:smart', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_collections'], }, }, + { + id: 'productsFirst', + title: 'Max Products In Collection', + type: 'short-input', + placeholder: 'Defaults to 50, max 250', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_get_collection'], + }, + }, ], tools: { access: [ @@ -533,6 +638,7 @@ export const ShopifyBlock: BlockConfig = { return params.operation || 'shopify_list_products' }, params: (params) => { + const first = parseOptionalNumberInput(params.first, 'first') const baseParams: Record = { oauthCredential: params.oauthCredential, shopDomain: params.shopDomain?.trim(), @@ -569,6 +675,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_products': return { ...baseParams, + first, query: params.productQuery?.trim(), } @@ -612,7 +719,9 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_orders': return { ...baseParams, + first, status: params.orderStatus !== 'any' ? params.orderStatus : undefined, + query: params.orderQuery?.trim(), } case 'shopify_update_order': @@ -641,6 +750,12 @@ export const ShopifyBlock: BlockConfig = { ...baseParams, orderId: params.orderId.trim(), reason: params.cancelReason, + restock: parseOptionalBooleanInput(params.restock) ?? false, + notifyCustomer: parseOptionalBooleanInput(params.cancelNotifyCustomer), + refundMethod: + parseOptionalBooleanInput(params.refundOriginalPayment) === true + ? { originalPaymentMethodsRefund: true } + : undefined, staffNote: params.staffNote?.trim(), } @@ -658,7 +773,6 @@ export const ShopifyBlock: BlockConfig = { ?.split(',') .map((t: string) => t.trim()) .filter(Boolean), - acceptsMarketing: params.acceptsMarketing, } case 'shopify_get_customer': @@ -673,6 +787,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_customers': return { ...baseParams, + first, query: params.customerQuery?.trim(), } @@ -707,6 +822,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_inventory_items': return { ...baseParams, + first, query: params.inventoryQuery?.trim(), } @@ -741,6 +857,8 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_locations': return { ...baseParams, + first, + includeInactive: parseOptionalBooleanInput(params.includeInactive), } // Fulfillment Operations @@ -754,13 +872,14 @@ export const ShopifyBlock: BlockConfig = { trackingNumber: params.trackingNumber?.trim(), trackingCompany: params.trackingCompany?.trim(), trackingUrl: params.trackingUrl?.trim(), - notifyCustomer: params.notifyCustomer, + notifyCustomer: parseOptionalBooleanInput(params.notifyCustomer), } // Collection Operations case 'shopify_list_collections': return { ...baseParams, + first, query: params.collectionQuery?.trim(), } @@ -771,6 +890,7 @@ export const ShopifyBlock: BlockConfig = { return { ...baseParams, collectionId: params.collectionId.trim(), + productsFirst: parseOptionalNumberInput(params.productsFirst, 'productsFirst'), } default: @@ -791,14 +911,22 @@ export const ShopifyBlock: BlockConfig = { vendor: { type: 'string', description: 'Product vendor' }, tags: { type: 'string', description: 'Tags (comma-separated)' }, status: { type: 'string', description: 'Product status' }, - query: { type: 'string', description: 'Search query' }, + productQuery: { type: 'string', description: 'Product search query' }, + first: { type: 'number', description: 'Maximum number of results to return' }, // Order inputs orderId: { type: 'string', description: 'Order ID' }, orderStatus: { type: 'string', description: 'Order status filter' }, + orderQuery: { type: 'string', description: 'Order search query' }, orderNote: { type: 'string', description: 'Order note' }, orderEmail: { type: 'string', description: 'Order customer email' }, orderTags: { type: 'string', description: 'Order tags' }, cancelReason: { type: 'string', description: 'Order cancellation reason' }, + restock: { type: 'boolean', description: 'Whether to restock cancelled items' }, + cancelNotifyCustomer: { type: 'boolean', description: 'Whether to notify the customer' }, + refundOriginalPayment: { + type: 'boolean', + description: 'Whether to refund to the original payment method', + }, staffNote: { type: 'string', description: 'Staff note for order cancellation' }, // Customer inputs customerId: { type: 'string', description: 'Customer ID' }, @@ -808,7 +936,7 @@ export const ShopifyBlock: BlockConfig = { phone: { type: 'string', description: 'Customer phone' }, customerNote: { type: 'string', description: 'Customer note' }, customerTags: { type: 'string', description: 'Customer tags' }, - acceptsMarketing: { type: 'boolean', description: 'Accepts marketing' }, + customerQuery: { type: 'string', description: 'Customer search query' }, // Inventory inputs inventoryQuery: { type: 'string', description: 'Inventory search query' }, inventoryItemId: { type: 'string', description: 'Inventory item ID' }, @@ -820,30 +948,81 @@ export const ShopifyBlock: BlockConfig = { trackingCompany: { type: 'string', description: 'Shipping carrier name' }, trackingUrl: { type: 'string', description: 'Tracking URL' }, notifyCustomer: { type: 'boolean', description: 'Send shipping notification email' }, + includeInactive: { type: 'boolean', description: 'Include inactive locations in results' }, // Collection inputs collectionId: { type: 'string', description: 'Collection ID' }, collectionQuery: { type: 'string', description: 'Collection search query' }, + productsFirst: { type: 'number', description: 'Maximum number of products to return' }, }, outputs: { // Product outputs - product: { type: 'json', description: 'Product data' }, - products: { type: 'json', description: 'Products list' }, + product: { + type: 'json', + description: + 'Product details (id, title, handle, descriptionHtml, vendor, productType, tags, status, variants, images)', + }, + products: { + type: 'json', + description: 'List of products with core product fields and media summaries', + }, // Order outputs - order: { type: 'json', description: 'Order data' }, - orders: { type: 'json', description: 'Orders list' }, + order: { + type: 'json', + description: + 'Order details or cancellation result depending on the operation (order fields, customer, totals, notes, line items, or cancellation job status)', + }, + orders: { + type: 'json', + description: 'List of orders with status, totals, customer, and shipping summary fields', + }, // Customer outputs - customer: { type: 'json', description: 'Customer data' }, - customers: { type: 'json', description: 'Customers list' }, + customer: { + type: 'json', + description: + 'Customer details (id, email, name, phone, note, tags, amountSpent, addresses, defaultAddress)', + }, + customers: { + type: 'json', + description: 'List of customers with contact details, tags, spend, and default address', + }, // Inventory outputs - inventoryItems: { type: 'json', description: 'Inventory items list' }, - inventoryLevel: { type: 'json', description: 'Inventory level data' }, + inventoryItems: { + type: 'json', + description: + 'Inventory items with SKU, tracking status, variant details, and per-location stock', + }, + inventoryLevel: { + type: 'json', + description: + 'Inventory levels for an item or an inventory adjustment result (levels by location, or adjustmentGroup and changes)', + }, // Location outputs - locations: { type: 'json', description: 'Locations list' }, + locations: { + type: 'json', + description: + 'Store locations with id, name, active status, fulfillment capability, and address', + }, // Fulfillment outputs - fulfillment: { type: 'json', description: 'Fulfillment data' }, + fulfillment: { + type: 'json', + description: + 'Fulfillment result (id, status, trackingInfo, createdAt, updatedAt, fulfillmentLineItems)', + }, // Collection outputs - collection: { type: 'json', description: 'Collection data with products' }, - collections: { type: 'json', description: 'Collections list' }, + collection: { + type: 'json', + description: + 'Collection details (id, title, handle, descriptionHtml, image, sortOrder, productsCount, products)', + }, + collections: { + type: 'json', + description: + 'List of collections with id, title, handle, product counts, sort order, and image', + }, + pageInfo: { + type: 'json', + description: 'Pagination info for list operations (hasNextPage, hasPreviousPage)', + }, // Delete outputs deletedId: { type: 'string', description: 'ID of deleted resource' }, // Success indicator diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index c0d9511bfcc..d06251d89a5 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -2,23 +2,63 @@ import { TrelloIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import type { ToolResponse } from '@/tools/types' +import { parseOptionalBooleanInput, parseOptionalNumberInput } from '@/blocks/utils' +import type { TrelloResponse } from '@/tools/trello' + +function getTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function parseStringArray(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const items = value + .flatMap((item) => (typeof item === 'string' ? [item.trim()] : [])) + .filter((item) => item.length > 0) + + return items.length > 0 ? items : undefined + } + + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) + return parseStringArray(parsed) + } catch { + return undefined + } + } + + const items = trimmed + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + return items.length > 0 ? items : undefined +} /** - * Trello Block - * - * Note: Trello uses OAuth 1.0a authentication with a unique credential ID format - * (non-UUID strings like CUID2). This is different from most OAuth 2.0 providers - * that use UUID-based credential IDs. The OAuth credentials API has been updated - * to accept both UUID and non-UUID credential ID formats to support Trello. + * Trello uses a custom token flow and non-UUID credential IDs, so the block keeps + * the normal OAuth block UX while relying on the custom Trello auth routes. */ -export const TrelloBlock: BlockConfig = { +export const TrelloBlock: BlockConfig = { type: 'trello', name: 'Trello', - description: 'Manage Trello boards and cards', + description: 'Manage Trello lists, cards, and activity', authMode: AuthMode.OAuth, longDescription: - 'Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments.', + 'Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.', docsLink: 'https://docs.sim.ai/tools/trello', category: 'tools', integrationType: IntegrationType.Productivity, @@ -60,7 +100,6 @@ export const TrelloBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, - { id: 'boardSelector', title: 'Board', @@ -74,111 +113,105 @@ export const TrelloBlock: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: [ - 'trello_list_lists', - 'trello_list_cards', - 'trello_create_card', - 'trello_get_actions', - ], + value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], + value: 'trello_list_lists', }, }, { - id: 'boardId', + id: 'manualBoardId', title: 'Board ID', type: 'short-input', canonicalParamId: 'boardId', - placeholder: 'Enter board ID', + placeholder: 'Enter Trello board ID', mode: 'advanced', condition: { field: 'operation', - value: [ - 'trello_list_lists', - 'trello_list_cards', - 'trello_create_card', - 'trello_get_actions', - ], + value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], + value: 'trello_list_lists', }, }, { id: 'listId', - title: 'List (Optional)', + title: 'List ID', type: 'short-input', - placeholder: 'Enter list ID to filter cards by list', + placeholder: 'Enter Trello list ID', condition: { field: 'operation', - value: 'trello_list_cards', + value: ['trello_list_cards', 'trello_create_card'], + }, + required: { + field: 'operation', + value: 'trello_create_card', }, }, { - id: 'listId', - title: 'List', + id: 'cardId', + title: 'Card ID', type: 'short-input', - placeholder: 'Enter list ID or search for a list', + placeholder: 'Enter Trello card ID', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_update_card', 'trello_get_actions', 'trello_add_comment'], + }, + required: { + field: 'operation', + value: ['trello_update_card', 'trello_add_comment'], }, - required: true, }, - { id: 'name', title: 'Card Name', type: 'short-input', - placeholder: 'Enter card name/title', + placeholder: 'Enter card name', condition: { + field: 'operation', + value: ['trello_create_card', 'trello_update_card'], + }, + required: { field: 'operation', value: 'trello_create_card', }, - required: true, }, - { id: 'desc', title: 'Description', type: 'long-input', - placeholder: 'Enter card description (optional)', + placeholder: 'Enter card description', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_card'], }, }, - { id: 'pos', title: 'Position', - type: 'dropdown', - options: [ - { label: 'Top', id: 'top' }, - { label: 'Bottom', id: 'bottom' }, - ], + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', condition: { field: 'operation', value: 'trello_create_card', }, }, - { id: 'due', title: 'Due Date', type: 'short-input', - placeholder: 'YYYY-MM-DD or ISO 8601', + placeholder: 'YYYY-MM-DD or ISO 8601 timestamp', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_card'], }, wandConfig: { enabled: true, prompt: `Generate a date or timestamp based on the user's description. -The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC timezone). +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. Examples: - "tomorrow" -> Calculate tomorrow's date in YYYY-MM-DD format - "next Friday" -> Calculate the next Friday in YYYY-MM-DD format @@ -186,129 +219,77 @@ Examples: - "end of month" -> Calculate the last day of the current month - "next week at 3pm" -> Calculate next week's date at 15:00:00Z -Return ONLY the date/timestamp string - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the due date (e.g., "next Friday", "in 2 weeks")...', +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the due date (e.g. "next Friday", "in 2 weeks")...', generationType: 'timestamp', }, }, - - { - id: 'labels', - title: 'Labels', - type: 'short-input', - placeholder: 'Comma-separated label IDs (optional)', - condition: { - field: 'operation', - value: 'trello_create_card', - }, - }, - { - id: 'cardId', - title: 'Card', - type: 'short-input', - placeholder: 'Enter card ID or search for a card', + id: 'dueComplete', + title: 'Due Status', + type: 'dropdown', + options: [ + { label: 'Leave Unset', id: '' }, + { label: 'Complete', id: 'true' }, + { label: 'Incomplete', id: 'false' }, + ], + value: () => '', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_update_card', + value: ['trello_create_card', 'trello_update_card'], }, - required: true, }, - { - id: 'name', - title: 'New Card Name', + id: 'labelIds', + title: 'Label IDs', type: 'short-input', - placeholder: 'Enter new card name (leave empty to keep current)', + placeholder: 'Comma-separated label IDs', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_update_card', + value: 'trello_create_card', }, - }, - - { - id: 'desc', - title: 'New Description', - type: 'long-input', - placeholder: 'Enter new description (leave empty to keep current)', - condition: { - field: 'operation', - value: 'trello_update_card', + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello label IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the label IDs to include...', }, }, - { id: 'closed', - title: 'Archive Card', - type: 'switch', - condition: { - field: 'operation', - value: 'trello_update_card', - }, - }, - - { - id: 'dueComplete', - title: 'Mark Due Date Complete', - type: 'switch', + title: 'Archive Status', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Archive Card', id: 'true' }, + { label: 'Reopen Card', id: 'false' }, + ], + value: () => '', + mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', }, }, - { id: 'idList', - title: 'Move to List', + title: 'Move to List ID', type: 'short-input', - placeholder: 'Enter list ID to move card', + placeholder: 'Enter Trello list ID', + mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', }, }, - - { - id: 'due', - title: 'Due Date', - type: 'short-input', - placeholder: 'YYYY-MM-DD or ISO 8601', - condition: { - field: 'operation', - value: 'trello_update_card', - }, - wandConfig: { - enabled: true, - prompt: `Generate a date or timestamp based on the user's description. -The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC timezone). -Examples: -- "tomorrow" -> Calculate tomorrow's date in YYYY-MM-DD format -- "next Friday" -> Calculate the next Friday in YYYY-MM-DD format -- "in 3 days" -> Calculate 3 days from now in YYYY-MM-DD format -- "end of month" -> Calculate the last day of the current month -- "next week at 3pm" -> Calculate next week's date at 15:00:00Z - -Return ONLY the date/timestamp string - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the due date (e.g., "next Friday", "in 2 weeks")...', - generationType: 'timestamp', - }, - }, - - { - id: 'cardId', - title: 'Card ID', - type: 'short-input', - placeholder: 'Enter card ID to get card actions', - condition: { - field: 'operation', - value: 'trello_get_actions', - }, - }, { id: 'filter', title: 'Action Filter', type: 'short-input', - placeholder: 'e.g., commentCard,updateCard', + placeholder: 'commentCard,updateCard,createCard or all', + mode: 'advanced', condition: { field: 'operation', value: 'trello_get_actions', @@ -316,26 +297,26 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex }, { id: 'limit', - title: 'Limit', + title: 'Board Action Limit', type: 'short-input', - placeholder: '50', + placeholder: 'Maximum number of board actions', + mode: 'advanced', condition: { field: 'operation', value: 'trello_get_actions', }, }, { - id: 'cardId', - title: 'Card', + id: 'page', + title: 'Action Page', type: 'short-input', - placeholder: 'Enter card ID or search for a card', + placeholder: 'Page number for board or card actions', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_add_comment', + value: 'trello_get_actions', }, - required: true, }, - { id: 'text', title: 'Comment', @@ -358,99 +339,190 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex 'trello_add_comment', ], config: { - tool: (params) => { - switch (params.operation) { - case 'trello_list_lists': - return 'trello_list_lists' - case 'trello_list_cards': - return 'trello_list_cards' - case 'trello_create_card': - return 'trello_create_card' - case 'trello_update_card': - return 'trello_update_card' - case 'trello_get_actions': - return 'trello_get_actions' - case 'trello_add_comment': - return 'trello_add_comment' - default: - return 'trello_list_lists' - } - }, + tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists', params: (params) => { - const { operation, limit, closed, dueComplete, ...rest } = params + const operation = getTrimmedString(params.operation) ?? 'trello_list_lists' + const baseParams: Record = { + oauthCredential: params.oauthCredential, + } - const result: Record = { ...rest } + switch (operation) { + case 'trello_list_lists': { + const boardId = getTrimmedString(params.boardId) - if (limit && operation === 'trello_get_actions') { - result.limit = Number.parseInt(limit, 10) - } + if (!boardId) { + throw new Error('Board ID is required.') + } - if (closed !== undefined && operation === 'trello_update_card') { - if (typeof closed === 'string') { - result.closed = closed.toLowerCase() === 'true' || closed === '1' - } else if (typeof closed === 'number') { - result.closed = closed !== 0 - } else { - result.closed = Boolean(closed) + return { + ...baseParams, + boardId, + } } - } - if (dueComplete !== undefined && operation === 'trello_update_card') { - if (typeof dueComplete === 'string') { - result.dueComplete = dueComplete.toLowerCase() === 'true' || dueComplete === '1' - } else if (typeof dueComplete === 'number') { - result.dueComplete = dueComplete !== 0 - } else { - result.dueComplete = Boolean(dueComplete) + case 'trello_list_cards': { + const boardId = getTrimmedString(params.boardId) + const listId = getTrimmedString(params.listId) + + if (boardId && listId) { + throw new Error('Provide either a board ID or list ID, not both.') + } + + if (!boardId && !listId) { + throw new Error('Provide either a board ID or list ID.') + } + + return { + ...baseParams, + boardId, + listId, + } } - } - return result + case 'trello_create_card': { + const listId = getTrimmedString(params.listId) + const name = getTrimmedString(params.name) + + if (!listId) { + throw new Error('List ID is required.') + } + + if (!name) { + throw new Error('Card name is required.') + } + + return { + ...baseParams, + listId, + name, + desc: getTrimmedString(params.desc), + pos: getTrimmedString(params.pos), + due: getTrimmedString(params.due), + dueComplete: parseOptionalBooleanInput(params.dueComplete), + labelIds: parseStringArray(params.labelIds), + } + } + + case 'trello_update_card': { + const cardId = getTrimmedString(params.cardId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + return { + ...baseParams, + cardId, + name: getTrimmedString(params.name), + desc: getTrimmedString(params.desc), + closed: parseOptionalBooleanInput(params.closed), + idList: getTrimmedString(params.idList), + due: getTrimmedString(params.due), + dueComplete: parseOptionalBooleanInput(params.dueComplete), + } + } + + case 'trello_get_actions': { + const boardId = getTrimmedString(params.boardId) + const cardId = getTrimmedString(params.cardId) + + if (boardId && cardId) { + throw new Error('Provide either a board ID or card ID, not both.') + } + + if (!boardId && !cardId) { + throw new Error('Provide either a board ID or card ID.') + } + + return { + ...baseParams, + boardId, + cardId, + filter: getTrimmedString(params.filter), + limit: parseOptionalNumberInput(params.limit, 'limit'), + page: parseOptionalNumberInput(params.page, 'page'), + } + } + + case 'trello_add_comment': { + const cardId = getTrimmedString(params.cardId) + const text = getTrimmedString(params.text) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!text) { + throw new Error('Comment text is required.') + } + + return { + ...baseParams, + cardId, + text, + } + } + + default: + return baseParams + } }, }, }, inputs: { operation: { type: 'string', description: 'Trello operation to perform' }, oauthCredential: { type: 'string', description: 'Trello OAuth credential' }, - boardId: { type: 'string', description: 'Board ID' }, - listId: { type: 'string', description: 'List ID' }, - cardId: { type: 'string', description: 'Card ID' }, - name: { type: 'string', description: 'Card name/title' }, - desc: { type: 'string', description: 'Card or board description' }, - pos: { type: 'string', description: 'Card position (top, bottom, or number)' }, + boardId: { type: 'string', description: 'Trello board ID' }, + listId: { type: 'string', description: 'Trello list ID' }, + cardId: { type: 'string', description: 'Trello card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + pos: { type: 'string', description: 'Card position (top, bottom, or positive float)' }, due: { type: 'string', description: 'Due date in ISO 8601 format' }, - labels: { type: 'string', description: 'Comma-separated label IDs' }, - closed: { type: 'boolean', description: 'Archive/close status' }, - idList: { type: 'string', description: 'ID of list to move card to' }, - dueComplete: { type: 'boolean', description: 'Mark due date as complete' }, - filter: { type: 'string', description: 'Action type filter' }, - limit: { type: 'number', description: 'Maximum number of results' }, + dueComplete: { type: 'boolean', description: 'Whether the due date is complete' }, + labelIds: { + type: 'json', + description: 'Label IDs as an array or comma-separated string', + }, + closed: { type: 'boolean', description: 'Whether the card should be archived or reopened' }, + idList: { type: 'string', description: 'List ID to move the card to' }, + filter: { type: 'string', description: 'Trello action filter' }, + limit: { type: 'number', description: 'Maximum number of board actions to return' }, + page: { type: 'number', description: 'Page number for action results' }, text: { type: 'string', description: 'Comment text' }, }, outputs: { lists: { - type: 'array', - description: 'Array of list objects (for list_lists operation)', + type: 'json', + description: 'Board lists (id, name, closed, pos, idBoard)', }, cards: { - type: 'array', - description: 'Array of card objects (for list_cards operation)', + type: 'json', + description: + 'Cards (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', }, card: { type: 'json', - description: 'Card object (for create_card and update_card operations)', + description: + 'Created or updated card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', }, actions: { - type: 'array', - description: 'Array of action objects (for get_actions operation)', + type: 'json', + description: + 'Actions (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', }, comment: { type: 'json', - description: 'Comment object (for add_comment operation)', + description: + 'Created comment action (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', }, count: { type: 'number', - description: 'Number of items returned (lists, cards, actions)', + description: 'Number of returned lists, cards, or actions', + }, + error: { + type: 'string', + description: 'Error message when the Trello operation fails', }, }, } diff --git a/apps/sim/blocks/blocks/whatsapp.ts b/apps/sim/blocks/blocks/whatsapp.ts index c42ea29d3d3..870f46234aa 100644 --- a/apps/sim/blocks/blocks/whatsapp.ts +++ b/apps/sim/blocks/blocks/whatsapp.ts @@ -32,6 +32,20 @@ export const WhatsAppBlock: BlockConfig = { placeholder: 'Enter your message', required: true, }, + { + id: 'previewUrl', + title: 'Preview First Link', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + defaultValue: 'false', + description: + 'Have WhatsApp attempt to render a link preview for the first URL in the message.', + required: false, + mode: 'advanced', + }, { id: 'phoneNumberId', title: 'WhatsApp Phone Number ID', @@ -53,25 +67,109 @@ export const WhatsAppBlock: BlockConfig = { access: ['whatsapp_send_message'], config: { tool: () => 'whatsapp_send_message', + params: (params) => ({ + ...params, + previewUrl: + params.previewUrl === 'true' ? true : params.previewUrl === 'false' ? false : undefined, + }), }, }, inputs: { phoneNumber: { type: 'string', description: 'Recipient phone number' }, message: { type: 'string', description: 'Message text' }, + previewUrl: { type: 'boolean', description: 'Whether to render a preview for the first URL' }, phoneNumberId: { type: 'string', description: 'WhatsApp phone number ID' }, accessToken: { type: 'string', description: 'WhatsApp access token' }, }, outputs: { - // Send operation outputs success: { type: 'boolean', description: 'Send success status' }, messageId: { type: 'string', description: 'WhatsApp message identifier' }, + messageStatus: { + type: 'string', + description: 'Initial delivery state returned by the send API, such as accepted or paused', + }, + messagingProduct: { + type: 'string', + description: 'Messaging product returned by the send API', + }, + inputPhoneNumber: { + type: 'string', + description: 'Recipient phone number echoed by the send API', + }, + whatsappUserId: { + type: 'string', + description: 'Resolved WhatsApp user ID for the recipient', + }, + contacts: { + type: 'array', + description: + 'Recipient contacts returned by the send API (each item includes input and wa_id)', + }, + eventType: { + type: 'string', + description: 'Webhook classification such as incoming_message, message_status, or mixed', + }, + from: { type: 'string', description: 'Sender phone number from the first incoming message' }, + recipientId: { + type: 'string', + description: 'Recipient phone number from the first status update in the batch', + }, + phoneNumberId: { + type: 'string', + description: 'Business phone number ID from the first message or status item in the batch', + }, + displayPhoneNumber: { + type: 'string', + description: + 'Business display phone number from the first message or status item in the batch', + }, + text: { type: 'string', description: 'Text body from the first incoming text message' }, + timestamp: { + type: 'string', + description: 'Timestamp from the first message or status item in the batch', + }, + messageType: { + type: 'string', + description: + 'Type of the first incoming message in the batch, such as text, image, or system', + }, + status: { + type: 'string', + description: 'First outgoing message status in the batch, such as sent, delivered, or read', + }, + contact: { + type: 'json', + description: 'First sender contact in the webhook batch (wa_id, profile.name)', + }, + messages: { + type: 'json', + description: + 'All incoming message objects from the webhook batch, flattened across entries/changes', + }, + statuses: { + type: 'json', + description: + 'All message status objects from the webhook batch, flattened across entries/changes', + }, + webhookContacts: { + type: 'json', + description: 'All sender contact profiles from the webhook batch', + }, + conversation: { + type: 'json', + description: + 'Conversation metadata from the first status update in the batch (id, expiration_timestamp, origin.type)', + }, + pricing: { + type: 'json', + description: + 'Pricing metadata from the first status update in the batch (billable, pricing_model, category)', + }, + raw: { + type: 'json', + description: 'Full structured WhatsApp webhook payload', + }, error: { type: 'string', description: 'Error information if sending fails' }, - // Webhook trigger outputs - from: { type: 'string', description: 'Sender phone number' }, - to: { type: 'string', description: 'Recipient phone number' }, - text: { type: 'string', description: 'Message text content' }, - timestamp: { type: 'string', description: 'Message timestamp' }, - type: { type: 'string', description: 'Message type (text, image, etc.)' }, }, triggers: { enabled: true, diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index aa915441d73..38165082858 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -30,6 +30,7 @@ import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence' import { CredentialBlock } from '@/blocks/blocks/credential' +import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike' import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor' import { DagsterBlock } from '@/blocks/blocks/dagster' import { DatabricksBlock } from '@/blocks/blocks/databricks' @@ -249,6 +250,7 @@ export const registry: Record = { cloudflare: CloudflareBlock, cloudformation: CloudFormationBlock, cloudwatch: CloudWatchBlock, + crowdstrike: CrowdStrikeBlock, clay: ClayBlock, clerk: ClerkBlock, condition: ConditionBlock, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 0e147a3c7f3..b59e0ebd17f 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -66,7 +66,12 @@ vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: vi.fn(() => []), })) -import { getApiKeyCondition } from '@/blocks/utils' +import { + getApiKeyCondition, + parseOptionalBooleanInput, + parseOptionalJsonInput, + parseOptionalNumberInput, +} from '@/blocks/utils' const BASE_CLOUD_MODELS: Record = { 'gpt-4o': 'openai', @@ -265,3 +270,92 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) }) }) + +describe('parseOptionalJsonInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalJsonInput('', 'payload')).toBeUndefined() + expect(parseOptionalJsonInput(' ', 'payload')).toBeUndefined() + expect(parseOptionalJsonInput(undefined, 'payload')).toBeUndefined() + }) + + it('parses JSON strings', () => { + expect(parseOptionalJsonInput('{"a":1}', 'payload')).toEqual({ a: 1 }) + expect(parseOptionalJsonInput('["a","b"]', 'payload')).toEqual(['a', 'b']) + }) + + it('returns non-string values as-is', () => { + const value = { a: 1 } + expect(parseOptionalJsonInput(value, 'payload')).toBe(value) + }) + + it('throws a helpful error for invalid JSON', () => { + expect(() => parseOptionalJsonInput('{', 'payload')).toThrow(/Invalid JSON for payload/) + }) +}) + +describe('parseOptionalNumberInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalNumberInput('', 'limit')).toBeUndefined() + expect(parseOptionalNumberInput(' ', 'limit')).toBeUndefined() + expect(parseOptionalNumberInput(undefined, 'limit')).toBeUndefined() + }) + + it('parses number strings and number values', () => { + expect(parseOptionalNumberInput('42', 'limit')).toBe(42) + expect(parseOptionalNumberInput(7, 'limit')).toBe(7) + }) + + it('validates integer-only values', () => { + expect(parseOptionalNumberInput('42', 'limit', { integer: true })).toBe(42) + expect(() => parseOptionalNumberInput('1.5', 'limit', { integer: true })).toThrow( + /expected an integer/i + ) + }) + + it('validates min and max bounds', () => { + expect(parseOptionalNumberInput('10', 'limit', { min: 1, max: 20 })).toBe(10) + expect(() => parseOptionalNumberInput('0', 'limit', { min: 1 })).toThrow( + /limit must be at least 1/i + ) + expect(() => parseOptionalNumberInput('21', 'limit', { max: 20 })).toThrow( + /limit must be at most 20/i + ) + }) + + it('throws a helpful error for invalid numbers', () => { + expect(() => parseOptionalNumberInput('abc', 'limit')).toThrow(/Invalid number for limit/i) + }) +}) + +describe('parseOptionalBooleanInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalBooleanInput('')).toBeUndefined() + expect(parseOptionalBooleanInput(' ')).toBeUndefined() + expect(parseOptionalBooleanInput(undefined)).toBeUndefined() + }) + + it('passes through boolean values', () => { + expect(parseOptionalBooleanInput(true)).toBe(true) + expect(parseOptionalBooleanInput(false)).toBe(false) + }) + + it('supports numeric boolean values', () => { + expect(parseOptionalBooleanInput(1)).toBe(true) + expect(parseOptionalBooleanInput(0)).toBe(false) + expect(parseOptionalBooleanInput(5)).toBe(true) + }) + + it('supports trimmed and case-insensitive string values', () => { + expect(parseOptionalBooleanInput('true')).toBe(true) + expect(parseOptionalBooleanInput(' TRUE ')).toBe(true) + expect(parseOptionalBooleanInput('1')).toBe(true) + expect(parseOptionalBooleanInput('false')).toBe(false) + expect(parseOptionalBooleanInput(' False ')).toBe(false) + expect(parseOptionalBooleanInput('0')).toBe(false) + }) + + it('returns undefined for unrecognized string values', () => { + expect(parseOptionalBooleanInput('yes')).toBeUndefined() + expect(parseOptionalBooleanInput('no')).toBeUndefined() + }) +}) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a31bd10c725..64ce0eb7a7f 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -361,6 +361,123 @@ export function createVersionedToolSelector> } } +interface ParseOptionalNumberInputOptions { + integer?: boolean + max?: number + min?: number +} + +/** + * Parses an optional JSON-capable block input value. + * Returns `undefined` for empty values and throws a helpful error for invalid JSON strings. + */ +export function parseOptionalJsonInput(value: unknown, label: string): T | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + try { + return JSON.parse(trimmed) as T + } catch (error) { + throw new Error( + `Invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + return value as T +} + +/** + * Parses an optional numeric block input value. + * Returns `undefined` for empty values and throws when the provided value is not a valid number. + */ +export function parseOptionalNumberInput( + value: unknown, + label: string, + options: ParseOptionalNumberInputOptions = {} +): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + let parsed: number + + if (typeof value === 'number') { + parsed = value + } else if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + parsed = Number(trimmed) + } else { + throw new Error(`Invalid number for ${label}: expected a valid number.`) + } + + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid number for ${label}: expected a valid number.`) + } + + if (options.integer && !Number.isInteger(parsed)) { + throw new Error(`Invalid number for ${label}: expected an integer.`) + } + + if (options.min != null && parsed < options.min) { + throw new Error(`${label} must be at least ${options.min}.`) + } + + if (options.max != null && parsed > options.max) { + throw new Error(`${label} must be at most ${options.max}.`) + } + + return parsed +} + +/** + * Parses an optional boolean block input value. + * Returns `undefined` for empty or unrecognized values. + */ +export function parseOptionalBooleanInput(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'number') { + return value !== 0 + } + + if (typeof value !== 'string') { + return undefined + } + + const normalized = value.trim().toLowerCase() + if (normalized.length === 0) { + return undefined + } + + if (normalized === 'true' || normalized === '1') { + return true + } + + if (normalized === 'false' || normalized === '0') { + return false + } + + return undefined +} + const DEFAULT_MULTIPLE_FILES_ERROR = 'File reference must be a single file, not an array. Use to select one file.' diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0e4de0bf5e5..2f91eebc395 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps) { ) } +export function CrowdStrikeIcon(props: SVGProps) { + return ( + + + + ) +} + export function SearchIcon(props: SVGProps) { return ( { + const selectResponses: Array<{ limitResult?: unknown; whereResult?: unknown }> = [] + const mockDbSelect = vi.fn(() => { + const nextResponse = selectResponses.shift() + + if (!nextResponse) { + throw new Error('No queued db.select response') + } + + const builder = { + from: vi.fn(() => builder), + where: vi.fn(() => builder), + limit: vi.fn(async () => nextResponse.limitResult ?? nextResponse.whereResult ?? []), + then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) => + Promise.resolve(nextResponse.whereResult ?? nextResponse.limitResult ?? []).then( + resolve, + reject + ), + } + + return builder + }) + + return { + mockBlockOrgMembers: vi.fn(), + mockDbSelect, + mockLogger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, + mockUnblockOrgMembers: vi.fn(), + selectResponses, + } + }) + +vi.mock('@sim/db', () => ({ + db: { + select: mockDbSelect, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + member: { + organizationId: 'member.organizationId', + role: 'member.role', + userId: 'member.userId', + }, + organization: {}, + subscription: { + referenceId: 'subscription.referenceId', + stripeSubscriptionId: 'subscription.stripeSubscriptionId', + }, + user: { + email: 'user.email', + id: 'user.id', + name: 'user.name', + }, + userStats: { + billingBlocked: 'userStats.billingBlocked', + billingBlockedReason: 'userStats.billingBlockedReason', + userId: 'userStats.userId', + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => mockLogger), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(() => 'and'), + eq: vi.fn(() => 'eq'), + inArray: vi.fn(() => 'inArray'), + isNull: vi.fn(() => 'isNull'), + ne: vi.fn(() => 'ne'), + or: vi.fn(() => 'or'), +})) + +vi.mock('@/components/emails', () => ({ + PaymentFailedEmail: vi.fn(), + getEmailSubject: vi.fn(), + renderCreditPurchaseEmail: vi.fn(), +})) + +vi.mock('@/lib/billing/core/billing', () => ({ + calculateSubscriptionOverage: vi.fn(), +})) + +vi.mock('@/lib/billing/credits/balance', () => ({ + addCredits: vi.fn(), + getCreditBalance: vi.fn(), + removeCredits: vi.fn(), +})) + +vi.mock('@/lib/billing/credits/purchase', () => ({ + setUsageLimitForCredits: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + blockOrgMembers: mockBlockOrgMembers, + unblockOrgMembers: mockUnblockOrgMembers, +})) + +vi.mock('@/lib/billing/plan-helpers', () => ({ + isEnterprise: vi.fn(() => false), + isOrgPlan: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))), + isTeam: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))), +})) + +vi.mock('@/lib/billing/stripe-client', () => ({ + requireStripeClient: vi.fn(), +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: vi.fn(() => 'https://sim.test'), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + sendEmail: vi.fn(), +})) + +vi.mock('@/lib/messaging/email/utils', () => ({ + getPersonalEmailFrom: vi.fn(() => ({ + from: 'billing@sim.test', + replyTo: 'support@sim.test', + })), +})) + +vi.mock('@/lib/messaging/email/validation', () => ({ + quickValidateEmail: vi.fn(() => ({ isValid: true })), +})) + +vi.mock('@react-email/render', () => ({ + render: vi.fn(), +})) + +import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded } from './invoices' + +function queueSelectResponse(response: { limitResult?: unknown; whereResult?: unknown }) { + selectResponses.push(response) +} + +function createInvoiceEvent( + type: 'invoice.payment_failed' | 'invoice.payment_succeeded', + invoice: Partial +): Stripe.Event { + return { + data: { + object: invoice as Stripe.Invoice, + }, + id: `evt_${type}`, + type, + } as Stripe.Event +} + +describe('invoice billing recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + selectResponses.length = 0 + mockBlockOrgMembers.mockResolvedValue(2) + mockUnblockOrgMembers.mockResolvedValue(2) + }) + + it('blocks org members when a metadata-backed invoice payment fails', async () => { + queueSelectResponse({ + limitResult: [ + { + id: 'sub-db-1', + plan: 'team_8000', + referenceId: 'org-1', + stripeSubscriptionId: 'sub_stripe_1', + }, + ], + }) + + await handleInvoicePaymentFailed( + createInvoiceEvent('invoice.payment_failed', { + amount_due: 3582, + attempt_count: 2, + customer: 'cus_123', + customer_email: 'owner@sim.test', + hosted_invoice_url: 'https://stripe.test/invoices/in_123', + id: 'in_123', + metadata: { + billingPeriod: '2026-04', + subscriptionId: 'sub_stripe_1', + type: 'overage_threshold_billing_org', + }, + }) + ) + + expect(mockBlockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed') + expect(mockUnblockOrgMembers).not.toHaveBeenCalled() + }) + + it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => { + queueSelectResponse({ + limitResult: [ + { + id: 'sub-db-1', + plan: 'team_8000', + referenceId: 'org-1', + stripeSubscriptionId: 'sub_stripe_1', + }, + ], + }) + queueSelectResponse({ + whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }], + }) + queueSelectResponse({ + whereResult: [{ blocked: false }, { blocked: false }], + }) + + await handleInvoicePaymentSucceeded( + createInvoiceEvent('invoice.payment_succeeded', { + amount_paid: 3582, + billing_reason: 'manual', + customer: 'cus_123', + id: 'in_123', + metadata: { + billingPeriod: '2026-04', + subscriptionId: 'sub_stripe_1', + type: 'overage_threshold_billing_org', + }, + }) + ) + + expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed') + expect(mockBlockOrgMembers).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 635aa8314ac..398e40804cc 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -24,7 +24,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation' const logger = createLogger('StripeInvoiceWebhooks') -const OVERAGE_INVOICE_TYPES = new Set([ +const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set([ 'overage_billing', 'overage_threshold_billing', 'overage_threshold_billing_org', @@ -35,6 +35,116 @@ function parseDecimal(value: string | number | null | undefined): number { return Number.parseFloat(value.toString()) } +type InvoiceSubscriptionResolutionSource = + | 'parent.subscription_details.subscription' + | 'metadata.subscriptionId' + | 'none' + +interface InvoiceSubscriptionContext { + invoiceType: string | null + resolutionSource: InvoiceSubscriptionResolutionSource + stripeSubscriptionId: string | null +} + +type BillingSubscription = typeof subscriptionTable.$inferSelect + +interface ResolvedInvoiceSubscription extends InvoiceSubscriptionContext { + sub: BillingSubscription + stripeSubscriptionId: string +} + +function resolveInvoiceSubscriptionContext(invoice: Stripe.Invoice): InvoiceSubscriptionContext { + const invoiceType = invoice.metadata?.type ?? null + const canResolveFromMetadata = !!( + invoiceType && METADATA_SUBSCRIPTION_INVOICE_TYPES.has(invoiceType) + ) + const metadataSubscriptionId = + canResolveFromMetadata && + typeof invoice.metadata?.subscriptionId === 'string' && + invoice.metadata.subscriptionId.length > 0 + ? invoice.metadata.subscriptionId + : null + + const parentSubscription = invoice.parent?.subscription_details?.subscription + const parentSubscriptionId = + typeof parentSubscription === 'string' ? parentSubscription : (parentSubscription?.id ?? null) + + if ( + parentSubscriptionId && + metadataSubscriptionId && + parentSubscriptionId !== metadataSubscriptionId + ) { + logger.warn('Invoice has conflicting subscription identifiers', { + invoiceId: invoice.id, + invoiceType, + metadataSubscriptionId, + parentSubscriptionId, + }) + } + + if (parentSubscriptionId) { + return { + invoiceType, + resolutionSource: 'parent.subscription_details.subscription', + stripeSubscriptionId: parentSubscriptionId, + } + } + + if (metadataSubscriptionId) { + return { + invoiceType, + resolutionSource: 'metadata.subscriptionId', + stripeSubscriptionId: metadataSubscriptionId, + } + } + + return { + invoiceType, + resolutionSource: 'none', + stripeSubscriptionId: null, + } +} + +async function resolveInvoiceSubscription( + invoice: Stripe.Invoice, + handlerName: string +): Promise { + const subscriptionContext = resolveInvoiceSubscriptionContext(invoice) + + if (!subscriptionContext.stripeSubscriptionId) { + logger.info('No subscription found on invoice; skipping handler', { + handlerName, + invoiceId: invoice.id, + invoiceType: subscriptionContext.invoiceType, + resolutionSource: subscriptionContext.resolutionSource, + }) + return null + } + + const records = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.stripeSubscriptionId, subscriptionContext.stripeSubscriptionId)) + .limit(1) + + if (records.length === 0) { + logger.warn('Subscription not found in database for invoice', { + handlerName, + invoiceId: invoice.id, + invoiceType: subscriptionContext.invoiceType, + resolutionSource: subscriptionContext.resolutionSource, + stripeSubscriptionId: subscriptionContext.stripeSubscriptionId, + }) + return null + } + + return { + ...subscriptionContext, + stripeSubscriptionId: subscriptionContext.stripeSubscriptionId, + sub: records[0], + } +} + /** * Create a billing portal URL for a Stripe customer */ @@ -462,21 +572,12 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { return } - // Handle subscription invoices - const subscription = invoice.parent?.subscription_details?.subscription - const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id - if (!stripeSubscriptionId) { + const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_succeeded') + if (!resolvedInvoice) { return } - const records = await db - .select() - .from(subscriptionTable) - .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) - .limit(1) - - if (records.length === 0) return - const sub = records[0] + const { sub } = resolvedInvoice // Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it let wasBlocked = false @@ -550,27 +651,13 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - const invoiceType = invoice.metadata?.type - const isOverageInvoice = !!(invoiceType && OVERAGE_INVOICE_TYPES.has(invoiceType)) - let stripeSubscriptionId: string | undefined - - if (isOverageInvoice) { - // Overage invoices store subscription ID in metadata - stripeSubscriptionId = invoice.metadata?.subscriptionId as string | undefined - } else { - // Regular subscription invoices have it in parent.subscription_details - const subscription = invoice.parent?.subscription_details?.subscription - stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id - } - - if (!stripeSubscriptionId) { - logger.info('No subscription found on invoice; skipping payment failed handler', { - invoiceId: invoice.id, - isOverageInvoice, - }) + const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed') + if (!resolvedInvoice) { return } + const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice + // Extract and validate customer ID const customerId = invoice.customer if (!customerId || typeof customerId !== 'string') { @@ -593,75 +680,57 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { attemptCount, customerEmail: invoice.customer_email, hostedInvoiceUrl: invoice.hosted_invoice_url, - isOverageInvoice, - invoiceType: isOverageInvoice ? 'overage' : 'subscription', + invoiceType: invoiceType ?? 'subscription', + resolutionSource, }) // Block users after first payment failure if (attemptCount >= 1) { - const records = await db - .select() - .from(subscriptionTable) - .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) - .limit(1) - - if (records.length > 0) { - const sub = records[0] + logger.error('Payment failure - blocking users', { + customerId, + attemptCount, + invoiceId: invoice.id, + invoiceType: invoiceType ?? 'subscription', + resolutionSource, + stripeSubscriptionId, + }) - logger.error('Payment failure - blocking users', { - invoiceId: invoice.id, - customerId, - attemptCount, - isOverageInvoice, - stripeSubscriptionId, + if (isOrgPlan(sub.plan)) { + const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') + logger.info('Blocked team/enterprise members due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + memberCount, + organizationId: sub.referenceId, }) - - if (isOrgPlan(sub.plan)) { - const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') - logger.info('Blocked team/enterprise members due to payment failure', { - organizationId: sub.referenceId, - memberCount, - isOverageInvoice, - }) - } else { - // Don't overwrite dispute blocks (dispute > payment_failed priority) - await db - .update(userStats) - .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) - .where( - and( - eq(userStats.userId, sub.referenceId), - or( - ne(userStats.billingBlockedReason, 'dispute'), - isNull(userStats.billingBlockedReason) - ) + } else { + await db + .update(userStats) + .set({ billingBlocked: true, billingBlockedReason: 'payment_failed' }) + .where( + and( + eq(userStats.userId, sub.referenceId), + or( + ne(userStats.billingBlockedReason, 'dispute'), + isNull(userStats.billingBlockedReason) ) ) - logger.info('Blocked user due to payment failure', { - userId: sub.referenceId, - isOverageInvoice, - }) - } + ) + logger.info('Blocked user due to payment failure', { + invoiceType: invoiceType ?? 'subscription', + userId: sub.referenceId, + }) + } - // Send payment failure notification emails - // Only send on FIRST failure (attempt_count === 1), not on Stripe's automatic retries - // This prevents spamming users with duplicate emails every 3-5-7 days - if (attemptCount === 1) { - await sendPaymentFailureEmails(sub, invoice, customerId) - logger.info('Payment failure email sent on first attempt', { - invoiceId: invoice.id, - customerId, - }) - } else { - logger.info('Skipping payment failure email on retry attempt', { - invoiceId: invoice.id, - attemptCount, - customerId, - }) - } + if (attemptCount === 1) { + await sendPaymentFailureEmails(sub, invoice, customerId) + logger.info('Payment failure email sent on first attempt', { + customerId, + invoiceId: invoice.id, + }) } else { - logger.warn('Subscription not found in database for failed payment', { - stripeSubscriptionId, + logger.info('Skipping payment failure email on retry attempt', { + attemptCount, + customerId, invoiceId: invoice.id, }) } diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 1d817d71cb3..91db5086698 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -261,8 +261,8 @@ export const SCOPE_DESCRIPTIONS: Record = { data: 'Access Wealthbox data', // Linear scopes - read: 'Read access to workspace', - write: 'Write access to Linear workspace', + read: 'Read access to connected account data', + write: 'Write access to connected account data', // Slack scopes 'channels:read': 'View public channels', diff --git a/apps/sim/lib/webhooks/providers/whatsapp.test.ts b/apps/sim/lib/webhooks/providers/whatsapp.test.ts new file mode 100644 index 00000000000..f648bd776a0 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/whatsapp.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { createHmac } from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => ({ + db: {}, + workflowDeploymentVersion: {}, +})) + +vi.mock('@sim/db/schema', () => ({ + webhook: {}, +})) + +import { whatsappHandler } from './whatsapp' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('WhatsApp webhook provider', () => { + it('rejects deliveries when the app secret is not configured', async () => { + const response = await whatsappHandler.verifyAuth!({ + webhook: { id: 'wh_1' }, + workflow: { id: 'wf_1' }, + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 'wa-auth-missing-secret', + providerConfig: {}, + }) + + expect(response?.status).toBe(401) + await expect(response?.text()).resolves.toBe( + 'Unauthorized - WhatsApp app secret not configured' + ) + }) + + it('accepts a valid X-Hub-Signature-256 header for the exact raw payload', async () => { + const secret = 'test-secret' + const rawBody = + '{"entry":[{"changes":[{"field":"messages","value":{"messages":[{"id":"wamid.1"}]}}]}]}' + const signature = `sha256=${createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}` + + const response = await whatsappHandler.verifyAuth!({ + webhook: { id: 'wh_2' }, + workflow: { id: 'wf_2' }, + request: reqWithHeaders({ 'x-hub-signature-256': signature }), + rawBody, + requestId: 'wa-auth-valid-signature', + providerConfig: { appSecret: secret }, + }) + + expect(response).toBeNull() + }) + + it('builds a stable idempotency key for batched message and status payloads', () => { + const key = whatsappHandler.extractIdempotencyId!({ + entry: [ + { + changes: [ + { + field: 'messages', + value: { + messages: [{ id: 'wamid.message.1' }], + statuses: [ + { + id: 'wamid.status.1', + status: 'delivered', + timestamp: '1700000001', + }, + ], + }, + }, + ], + }, + ], + }) + + expect(key).toMatch(/^whatsapp:2:[a-f0-9]{64}$/) + }) + + it('flattens batched messages and statuses into trigger-friendly outputs', async () => { + const result = await whatsappHandler.formatInput!({ + webhook: { id: 'wh_3', providerConfig: {} }, + workflow: { id: 'wf_3', userId: 'user_3' }, + body: { + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + field: 'messages', + value: { + metadata: { + phone_number_id: '12345', + display_phone_number: '+1 555 0100', + }, + contacts: [ + { + wa_id: '15550101', + profile: { name: 'Alice' }, + }, + ], + messages: [ + { + id: 'wamid.message.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }, + ], + }, + }, + { + field: 'messages', + value: { + metadata: { + phone_number_id: '12345', + display_phone_number: '+1 555 0100', + }, + statuses: [ + { + id: 'wamid.status.1', + recipient_id: '15550102', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + }, + ], + }, + }, + ], + }, + ], + }, + headers: {}, + requestId: 'wa-format-batch', + }) + + const input = result.input as Record + + expect(input.eventType).toBe('mixed') + expect(input.messageId).toBe('wamid.message.1') + expect(input.phoneNumberId).toBe('12345') + expect(input.displayPhoneNumber).toBe('+1 555 0100') + expect(input.text).toBe('hello') + expect(input.status).toBe('delivered') + expect(input.contact).toEqual({ + wa_id: '15550101', + profile: { name: 'Alice' }, + }) + expect(input.webhookContacts).toEqual([ + { + wa_id: '15550101', + profile: { name: 'Alice' }, + }, + ]) + expect(input.messages).toEqual([ + { + messageId: 'wamid.message.1', + from: '15550101', + phoneNumberId: '12345', + displayPhoneNumber: '+1 555 0100', + text: 'hello', + timestamp: '1700000000', + messageType: 'text', + raw: { + id: 'wamid.message.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }, + }, + ]) + expect(input.statuses).toEqual([ + { + messageId: 'wamid.status.1', + recipientId: '15550102', + phoneNumberId: '12345', + displayPhoneNumber: '+1 555 0100', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + raw: { + id: 'wamid.status.1', + recipient_id: '15550102', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + }, + }, + ]) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 5f0116b2a1b..e1a76ef2188 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,8 +1,10 @@ +import { createHash, createHmac } from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { FormatInputContext, FormatInputResult, @@ -11,6 +13,122 @@ import type { const logger = createLogger('WebhookProvider:WhatsApp') +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function getWhatsAppChanges( + body: unknown +): Array<{ field?: string; value: Record }> { + if (!isRecord(body) || !Array.isArray(body.entry)) { + return [] + } + + const changes: Array<{ field?: string; value: Record }> = [] + + for (const entry of body.entry) { + if (!isRecord(entry) || !Array.isArray(entry.changes)) { + continue + } + + for (const change of entry.changes) { + if (!isRecord(change) || !isRecord(change.value)) { + continue + } + + changes.push({ + field: typeof change.field === 'string' ? change.field : undefined, + value: change.value, + }) + } + } + + return changes +} + +function normalizeWhatsAppContact(contact: Record) { + const profile = isRecord(contact.profile) ? contact.profile : undefined + + return { + wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : undefined, + profile: profile + ? { + name: typeof profile.name === 'string' ? profile.name : undefined, + } + : undefined, + } +} + +function normalizeWhatsAppMessage( + message: Record, + metadata?: Record +) { + const text = isRecord(message.text) ? message.text : undefined + + return { + messageId: typeof message.id === 'string' ? message.id : undefined, + from: typeof message.from === 'string' ? message.from : undefined, + phoneNumberId: + typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined, + displayPhoneNumber: + typeof metadata?.display_phone_number === 'string' + ? metadata.display_phone_number + : undefined, + text: typeof text?.body === 'string' ? text.body : undefined, + timestamp: typeof message.timestamp === 'string' ? message.timestamp : undefined, + messageType: typeof message.type === 'string' ? message.type : undefined, + raw: message, + } +} + +function normalizeWhatsAppStatus( + status: Record, + metadata?: Record +) { + return { + messageId: typeof status.id === 'string' ? status.id : undefined, + recipientId: typeof status.recipient_id === 'string' ? status.recipient_id : undefined, + phoneNumberId: + typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined, + displayPhoneNumber: + typeof metadata?.display_phone_number === 'string' + ? metadata.display_phone_number + : undefined, + status: typeof status.status === 'string' ? status.status : undefined, + timestamp: typeof status.timestamp === 'string' ? status.timestamp : undefined, + conversation: isRecord(status.conversation) ? status.conversation : undefined, + pricing: isRecord(status.pricing) ? status.pricing : undefined, + raw: status, + } +} + +function validateWhatsAppSignature(secret: string, signature: string, body: string): boolean { + try { + if (!signature.startsWith('sha256=')) { + logger.warn('WhatsApp signature has invalid format') + return false + } + + const providedSignature = signature.substring(7) + const computedSignature = createHmac('sha256', secret).update(body, 'utf8').digest('hex') + + return safeCompare(computedSignature, providedSignature) + } catch (error) { + logger.error('Error validating WhatsApp signature:', error) + return false + } +} + +function buildWhatsAppIdempotencyKey(keys: Set): string | null { + if (keys.size === 0) { + return null + } + + const sortedKeys = Array.from(keys).sort() + const digest = createHash('sha256').update(sortedKeys.join('|'), 'utf8').digest('hex') + return `whatsapp:${sortedKeys.length}:${digest}` +} + /** * Handle WhatsApp verification requests */ @@ -42,6 +160,7 @@ export async function handleWhatsAppVerification( .where( and( eq(webhook.provider, 'whatsapp'), + eq(webhook.path, path), eq(webhook.isActive, true), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -78,6 +197,29 @@ export async function handleWhatsAppVerification( } export const whatsappHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }) { + const appSecret = providerConfig.appSecret as string | undefined + if (!appSecret) { + logger.warn( + `[${requestId}] WhatsApp webhook missing appSecret in providerConfig — rejecting request` + ) + return new NextResponse('Unauthorized - WhatsApp app secret not configured', { status: 401 }) + } + + const signature = request.headers.get('x-hub-signature-256') + if (!signature) { + logger.warn(`[${requestId}] WhatsApp webhook missing signature header`) + return new NextResponse('Unauthorized - Missing WhatsApp signature', { status: 401 }) + } + + if (!validateWhatsAppSignature(appSecret, signature, rawBody)) { + logger.warn(`[${requestId}] WhatsApp signature verification failed`) + return new NextResponse('Unauthorized - Invalid WhatsApp signature', { status: 401 }) + } + + return null + }, + async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { const url = new URL(request.url) const mode = url.searchParams.get('hub.mode') @@ -86,33 +228,148 @@ export const whatsappHandler: WebhookProviderHandler = { return handleWhatsAppVerification(requestId, path, mode, token, challenge) }, + extractIdempotencyId(body: unknown) { + const keys = new Set() + + for (const { field, value } of getWhatsAppChanges(body)) { + if (Array.isArray(value.messages)) { + for (const message of value.messages) { + if (!isRecord(message) || typeof message.id !== 'string') { + continue + } + + keys.add(`${field ?? 'messages'}:message:${message.id}`) + } + } + + if (Array.isArray(value.statuses)) { + for (const status of value.statuses) { + if (!isRecord(status) || typeof status.id !== 'string') { + continue + } + + const statusValue = typeof status.status === 'string' ? status.status : '' + const timestamp = typeof status.timestamp === 'string' ? status.timestamp : '' + keys.add(`${field ?? 'messages'}:status:${status.id}:${statusValue}:${timestamp}`) + } + } + + if (Array.isArray(value.groups)) { + for (const group of value.groups) { + if (!isRecord(group) || typeof group.request_id !== 'string') { + continue + } + + keys.add(`${field ?? 'groups'}:group:${group.request_id}`) + } + } + } + + return buildWhatsAppIdempotencyKey(keys) + }, + + formatSuccessResponse() { + return new NextResponse(null, { status: 200 }) + }, + async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record - const entry = b?.entry as Array> | undefined - const changes = entry?.[0]?.changes as Array> | undefined - const data = changes?.[0]?.value as Record | undefined - const messages = (data?.messages as Array>) || [] - - if (messages.length > 0) { - const message = messages[0] - const metadata = data?.metadata as Record | undefined - const text = message.text as Record | undefined - return { - input: { - messageId: message.id, - from: message.from, - phoneNumberId: metadata?.phone_number_id, - text: text?.body, - timestamp: message.timestamp, - raw: JSON.stringify(message), - }, + const payload = isRecord(body) ? body : undefined + const contacts: Array<{ wa_id?: string; profile?: { name?: string } }> = [] + const messages: Array<{ + messageId?: string + from?: string + phoneNumberId?: string + displayPhoneNumber?: string + text?: string + timestamp?: string + messageType?: string + raw: Record + }> = [] + const statuses: Array<{ + messageId?: string + recipientId?: string + phoneNumberId?: string + displayPhoneNumber?: string + status?: string + timestamp?: string + conversation?: Record + pricing?: Record + raw: Record + }> = [] + + for (const { value } of getWhatsAppChanges(body)) { + const metadata = isRecord(value.metadata) ? value.metadata : undefined + + if (Array.isArray(value.contacts)) { + for (const contact of value.contacts) { + if (!isRecord(contact)) { + continue + } + + contacts.push(normalizeWhatsAppContact(contact)) + } + } + + if (Array.isArray(value.messages)) { + for (const message of value.messages) { + if (!isRecord(message)) { + continue + } + + messages.push(normalizeWhatsAppMessage(message, metadata)) + } } + + if (Array.isArray(value.statuses)) { + for (const status of value.statuses) { + if (!isRecord(status)) { + continue + } + + statuses.push(normalizeWhatsAppStatus(status, metadata)) + } + } + } + + if (messages.length === 0 && statuses.length === 0) { + return { input: null } + } + + const firstMessage = messages[0] + const firstStatus = statuses[0] + + return { + input: { + eventType: + messages.length > 0 && statuses.length > 0 + ? 'mixed' + : messages.length > 0 + ? 'incoming_message' + : 'message_status', + messageId: firstMessage?.messageId ?? firstStatus?.messageId, + from: firstMessage?.from, + recipientId: firstStatus?.recipientId, + phoneNumberId: firstMessage?.phoneNumberId ?? firstStatus?.phoneNumberId, + displayPhoneNumber: firstMessage?.displayPhoneNumber ?? firstStatus?.displayPhoneNumber, + text: firstMessage?.text, + timestamp: firstMessage?.timestamp ?? firstStatus?.timestamp, + messageType: firstMessage?.messageType, + status: firstStatus?.status, + contact: contacts[0], + webhookContacts: contacts, + messages, + statuses, + conversation: firstStatus?.conversation, + pricing: firstStatus?.pricing, + raw: payload ?? body, + }, } - return { input: null } }, handleEmptyInput(requestId: string) { - logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) - return { message: 'No messages in WhatsApp payload' } + logger.info( + `[${requestId}] No messages or status updates in WhatsApp payload, skipping execution` + ) + return { message: 'No messages or status updates in WhatsApp payload' } }, } diff --git a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts new file mode 100644 index 00000000000..529bd7a5976 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts @@ -0,0 +1,161 @@ +import type { + CrowdStrikeGetSensorAggregatesParams, + CrowdStrikeGetSensorAggregatesResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetSensorAggregatesTool: ToolConfig< + CrowdStrikeGetSensorAggregatesParams, + CrowdStrikeGetSensorAggregatesResponse +> = { + id: 'crowdstrike_get_sensor_aggregates', + name: 'CrowdStrike Get Sensor Aggregates', + description: + 'Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + aggregateQuery: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON aggregate query body documented by CrowdStrike for sensor aggregates', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + aggregateQuery: params.aggregateQuery, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + operation: 'crowdstrike_get_sensor_aggregates', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike sensor aggregates') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + aggregates: { + type: 'array', + description: 'Aggregate result groups returned by CrowdStrike', + items: { + type: 'object', + properties: { + buckets: { + type: 'array', + description: 'Buckets within the aggregate result', + items: { + type: 'object', + properties: { + count: { + type: 'number', + description: 'Bucket document count', + optional: true, + }, + from: { + type: 'number', + description: 'Bucket lower bound', + optional: true, + }, + keyAsString: { + type: 'string', + description: 'String representation of the bucket key', + optional: true, + }, + label: { + type: 'json', + description: 'Bucket label object', + optional: true, + }, + stringFrom: { + type: 'string', + description: 'String lower bound', + optional: true, + }, + stringTo: { + type: 'string', + description: 'String upper bound', + optional: true, + }, + subAggregates: { + type: 'json', + description: 'Nested aggregate results for this bucket', + optional: true, + }, + to: { + type: 'number', + description: 'Bucket upper bound', + optional: true, + }, + value: { + type: 'number', + description: 'Bucket metric value', + optional: true, + }, + valueAsString: { + type: 'string', + description: 'String representation of the bucket value', + optional: true, + }, + }, + }, + }, + docCountErrorUpperBound: { + type: 'number', + description: 'Upper bound for bucket count error', + optional: true, + }, + name: { + type: 'string', + description: 'Aggregate result name', + optional: true, + }, + sumOtherDocCount: { + type: 'number', + description: 'Document count not included in the returned buckets', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of aggregate result groups returned', + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_sensor_details.ts b/apps/sim/tools/crowdstrike/get_sensor_details.ts new file mode 100644 index 00000000000..dcbf44de73a --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_sensor_details.ts @@ -0,0 +1,193 @@ +import type { + CrowdStrikeGetSensorDetailsParams, + CrowdStrikeGetSensorDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetSensorDetailsTool: ToolConfig< + CrowdStrikeGetSensorDetailsParams, + CrowdStrikeGetSensorDetailsResponse +> = { + id: 'crowdstrike_get_sensor_details', + name: 'CrowdStrike Get Sensor Details', + description: + 'Get documented CrowdStrike Identity Protection sensor details for one or more device IDs', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + ids: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike sensor device IDs', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + ids: params.ids, + operation: 'crowdstrike_get_sensor_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike sensor details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sensors: { + type: 'array', + description: 'CrowdStrike identity sensor detail records', + items: { + type: 'object', + properties: { + agentVersion: { + type: 'string', + description: 'Sensor agent version', + optional: true, + }, + cid: { + type: 'string', + description: 'CrowdStrike customer identifier', + }, + deviceId: { + type: 'string', + description: 'Sensor device identifier', + }, + heartbeatTime: { + type: 'number', + description: 'Last heartbeat timestamp', + optional: true, + }, + hostname: { + type: 'string', + description: 'Sensor hostname', + optional: true, + }, + idpPolicyId: { + type: 'string', + description: 'Assigned Identity Protection policy ID', + optional: true, + }, + idpPolicyName: { + type: 'string', + description: 'Assigned Identity Protection policy name', + optional: true, + }, + ipAddress: { + type: 'string', + description: 'Sensor local IP address', + optional: true, + }, + kerberosConfig: { + type: 'string', + description: 'Kerberos configuration status', + optional: true, + }, + ldapConfig: { + type: 'string', + description: 'LDAP configuration status', + optional: true, + }, + ldapsConfig: { + type: 'string', + description: 'LDAPS configuration status', + optional: true, + }, + machineDomain: { + type: 'string', + description: 'Machine domain', + optional: true, + }, + ntlmConfig: { + type: 'string', + description: 'NTLM configuration status', + optional: true, + }, + osVersion: { + type: 'string', + description: 'Operating system version', + optional: true, + }, + rdpToDcConfig: { + type: 'string', + description: 'RDP to domain controller configuration status', + optional: true, + }, + smbToDcConfig: { + type: 'string', + description: 'SMB to domain controller configuration status', + optional: true, + }, + status: { + type: 'string', + description: 'Sensor protection status', + optional: true, + }, + statusCauses: { + type: 'array', + description: 'Documented causes behind the current status', + optional: true, + items: { + type: 'string', + }, + }, + tiEnabled: { + type: 'string', + description: 'Threat intelligence enablement status', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of sensors returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata when returned by the underlying API', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/index.ts b/apps/sim/tools/crowdstrike/index.ts new file mode 100644 index 00000000000..e0878239332 --- /dev/null +++ b/apps/sim/tools/crowdstrike/index.ts @@ -0,0 +1,4 @@ +export { crowdstrikeGetSensorAggregatesTool } from './get_sensor_aggregates' +export { crowdstrikeGetSensorDetailsTool } from './get_sensor_details' +export { crowdstrikeQuerySensorsTool } from './query_sensors' +export * from './types' diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts new file mode 100644 index 00000000000..d6547815906 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -0,0 +1,213 @@ +import type { + CrowdStrikeQuerySensorsParams, + CrowdStrikeQuerySensorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQuerySensorsTool: ToolConfig< + CrowdStrikeQuerySensorsParams, + CrowdStrikeQuerySensorsResponse +> = { + id: 'crowdstrike_query_sensors', + name: 'CrowdStrike Query Sensors', + description: 'Search CrowdStrike identity protection sensors by hostname, IP, or related fields', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter for identity sensor search', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of sensor records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the identity sensor query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression for identity sensor results', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_sensors', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike sensors') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sensors: { + type: 'array', + description: 'Matching CrowdStrike identity sensor records', + items: { + type: 'object', + properties: { + agentVersion: { + type: 'string', + description: 'Sensor agent version', + optional: true, + }, + cid: { + type: 'string', + description: 'CrowdStrike customer identifier', + }, + deviceId: { + type: 'string', + description: 'Sensor device identifier', + }, + heartbeatTime: { + type: 'number', + description: 'Last heartbeat timestamp', + optional: true, + }, + hostname: { + type: 'string', + description: 'Sensor hostname', + optional: true, + }, + idpPolicyId: { + type: 'string', + description: 'Assigned Identity Protection policy ID', + optional: true, + }, + idpPolicyName: { + type: 'string', + description: 'Assigned Identity Protection policy name', + optional: true, + }, + ipAddress: { + type: 'string', + description: 'Sensor local IP address', + optional: true, + }, + kerberosConfig: { + type: 'string', + description: 'Kerberos configuration status', + optional: true, + }, + ldapConfig: { + type: 'string', + description: 'LDAP configuration status', + optional: true, + }, + ldapsConfig: { + type: 'string', + description: 'LDAPS configuration status', + optional: true, + }, + machineDomain: { + type: 'string', + description: 'Machine domain', + optional: true, + }, + ntlmConfig: { + type: 'string', + description: 'NTLM configuration status', + optional: true, + }, + osVersion: { + type: 'string', + description: 'Operating system version', + optional: true, + }, + rdpToDcConfig: { + type: 'string', + description: 'RDP to domain controller configuration status', + optional: true, + }, + smbToDcConfig: { + type: 'string', + description: 'SMB to domain controller configuration status', + optional: true, + }, + status: { + type: 'string', + description: 'Sensor protection status', + optional: true, + }, + statusCauses: { + type: 'array', + description: 'Documented causes behind the current status', + optional: true, + items: { + type: 'string', + }, + }, + tiEnabled: { + type: 'string', + description: 'Threat intelligence enablement status', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of sensors returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (limit, offset, total)', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts new file mode 100644 index 00000000000..101cd6afdea --- /dev/null +++ b/apps/sim/tools/crowdstrike/types.ts @@ -0,0 +1,137 @@ +import type { ToolResponse } from '@/tools/types' + +export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'eu-1' | 'us-gov-1' | 'us-gov-2' + +export interface CrowdStrikeBaseParams { + clientId: string + clientSecret: string + cloud: CrowdStrikeCloud +} + +export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeGetSensorDetailsParams extends CrowdStrikeBaseParams { + ids: string[] +} + +export interface CrowdStrikeAggregateDateRangeSpec { + from: string + to: string +} + +export interface CrowdStrikeAggregateExtendedBoundsSpec { + max: string + min: string +} + +export interface CrowdStrikeAggregateRangeSpec { + from: number + to: number +} + +export interface CrowdStrikeAggregateQuery { + date_ranges?: CrowdStrikeAggregateDateRangeSpec[] + exclude?: string + extended_bounds?: CrowdStrikeAggregateExtendedBoundsSpec + field?: string + filter?: string + from?: number + include?: string + interval?: string + max_doc_count?: number + min_doc_count?: number + missing?: string + name?: string + q?: string + ranges?: CrowdStrikeAggregateRangeSpec[] + size?: number + sort?: string + sub_aggregates?: CrowdStrikeAggregateQuery[] + time_zone?: string + type?: string +} + +export interface CrowdStrikeGetSensorAggregatesParams extends CrowdStrikeBaseParams { + aggregateQuery: CrowdStrikeAggregateQuery +} + +export interface CrowdStrikePagination { + limit: number | null + offset: number | null + total: number | null +} + +export interface CrowdStrikeSensor { + agentVersion: string | null + cid: string | null + deviceId: string | null + heartbeatTime: number | null + hostname: string | null + idpPolicyId: string | null + idpPolicyName: string | null + ipAddress: string | null + kerberosConfig: string | null + ldapConfig: string | null + ldapsConfig: string | null + machineDomain: string | null + ntlmConfig: string | null + osVersion: string | null + rdpToDcConfig: string | null + smbToDcConfig: string | null + status: string | null + statusCauses: string[] + tiEnabled: string | null +} + +export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { + output: { + count: number + pagination: CrowdStrikePagination | null + sensors: CrowdStrikeSensor[] + } +} + +export interface CrowdStrikeGetSensorDetailsResponse extends ToolResponse { + output: { + count: number + pagination: CrowdStrikePagination | null + sensors: CrowdStrikeSensor[] + } +} + +export interface CrowdStrikeSensorAggregateBucket { + count: number | null + from: number | null + keyAsString: string | null + label: Record | null + stringFrom: string | null + stringTo: string | null + subAggregates: CrowdStrikeSensorAggregateResult[] + to: number | null + value: number | null + valueAsString: string | null +} + +export interface CrowdStrikeSensorAggregateResult { + buckets: CrowdStrikeSensorAggregateBucket[] + docCountErrorUpperBound: number | null + name: string | null + sumOtherDocCount: number | null +} + +export interface CrowdStrikeGetSensorAggregatesResponse extends ToolResponse { + output: { + aggregates: CrowdStrikeSensorAggregateResult[] + count: number + } +} + +export type CrowdStrikeResponse = + | CrowdStrikeQuerySensorsResponse + | CrowdStrikeGetSensorDetailsResponse + | CrowdStrikeGetSensorAggregatesResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 76b98a0d87c..c26110f07ac 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -352,6 +352,11 @@ import { confluenceUpdateTool, confluenceUploadAttachmentTool, } from '@/tools/confluence' +import { + crowdstrikeGetSensorAggregatesTool, + crowdstrikeGetSensorDetailsTool, + crowdstrikeQuerySensorsTool, +} from '@/tools/crowdstrike' import { cursorAddFollowupTool, cursorAddFollowupV2Tool, @@ -3465,6 +3470,9 @@ export const tools: Record = { cloudwatch_list_metrics: cloudwatchListMetricsTool, cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, + crowdstrike_get_sensor_aggregates: crowdstrikeGetSensorAggregatesTool, + crowdstrike_get_sensor_details: crowdstrikeGetSensorDetailsTool, + crowdstrike_query_sensors: crowdstrikeQuerySensorsTool, dynamodb_get: dynamodbGetTool, dynamodb_put: dynamodbPutTool, dynamodb_query: dynamodbQueryTool, diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index a373406ad65..04780af9aaf 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -1,10 +1,13 @@ -import type { ShopifyAdjustInventoryParams, ShopifyInventoryResponse } from '@/tools/shopify/types' +import type { + ShopifyAdjustInventoryParams, + ShopifyInventoryAdjustmentResponse, +} from '@/tools/shopify/types' import { INVENTORY_ADJUSTMENT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' export const shopifyAdjustInventoryTool: ToolConfig< ShopifyAdjustInventoryParams, - ShopifyInventoryResponse + ShopifyInventoryAdjustmentResponse > = { id: 'shopify_adjust_inventory', name: 'Shopify Adjust Inventory', @@ -101,8 +104,8 @@ export const shopifyAdjustInventoryTool: ToolConfig< name: 'available', changes: [ { - inventoryItemId: params.inventoryItemId, - locationId: params.locationId, + inventoryItemId: params.inventoryItemId.trim(), + locationId: params.locationId.trim(), delta: params.delta, }, ], diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index 775eefe5f4f..97c9eb317c6 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -1,8 +1,11 @@ -import type { ShopifyCancelOrderParams, ShopifyOrderResponse } from '@/tools/shopify/types' +import type { ShopifyCancelOrderParams, ShopifyCancelOrderResponse } from '@/tools/shopify/types' import { CANCEL_ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' -export const shopifyCancelOrderTool: ToolConfig = { +export const shopifyCancelOrderTool: ToolConfig< + ShopifyCancelOrderParams, + ShopifyCancelOrderResponse +> = { id: 'shopify_cancel_order', name: 'Shopify Cancel Order', description: 'Cancel an order in your Shopify store', @@ -38,17 +41,18 @@ export const shopifyCancelOrderTool: ToolConfig - fulfillmentLineItems: Array<{ - id: string - quantity: number - lineItem: { - title: string - } - }> - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyCreateFulfillmentTool: ToolConfig< ShopifyCreateFulfillmentParams, - ShopifyCreateFulfillmentResponse + ShopifyFulfillmentResponse > = { id: 'shopify_create_fulfillment', name: 'Shopify Create Fulfillment', @@ -125,7 +97,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< } = { lineItemsByFulfillmentOrder: [ { - fulfillmentOrderId: params.fulfillmentOrderId, + fulfillmentOrderId: params.fulfillmentOrderId.trim(), }, ], notifyCustomer: params.notifyCustomer !== false, // Default to true @@ -138,8 +110,8 @@ export const shopifyCreateFulfillmentTool: ToolConfig< return { query: ` - mutation fulfillmentCreateV2($fulfillment: FulfillmentV2Input!) { - fulfillmentCreateV2(fulfillment: $fulfillment) { + mutation fulfillmentCreate($fulfillment: FulfillmentInput!) { + fulfillmentCreate(fulfillment: $fulfillment) { fulfillment { id status @@ -187,7 +159,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< } } - const result = data.data?.fulfillmentCreateV2 + const result = data.data?.fulfillmentCreate if (!result) { return { success: false, diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index a3b5855f536..bfee95f7c5f 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -101,8 +101,8 @@ export const shopifyCreateProductTool: ToolConfig< return { query: ` - mutation productCreate($input: ProductInput!) { - productCreate(input: $input) { + mutation productCreate($product: ProductCreateInput!) { + productCreate(product: $product) { product { id title @@ -144,7 +144,7 @@ export const shopifyCreateProductTool: ToolConfig< } `, variables: { - input, + product: input, }, } }, diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index c1e506838ed..480ea0ab07b 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -1,47 +1,10 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { ShopifyCollectionResponse, ShopifyGetCollectionParams } from '@/tools/shopify/types' import { COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyGetCollectionParams extends ShopifyBaseParams { - collectionId: string - productsFirst?: number -} - -interface ShopifyGetCollectionResponse extends ToolResponse { - output: { - collection?: { - id: string - title: string - handle: string - description: string | null - descriptionHtml: string | null - productsCount: number - sortOrder: string - updatedAt: string - image: { - url: string - altText: string | null - } | null - products: Array<{ - id: string - title: string - handle: string - status: string - vendor: string - productType: string - totalInventory: number - featuredImage: { - url: string - altText: string | null - } | null - }> - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyGetCollectionTool: ToolConfig< ShopifyGetCollectionParams, - ShopifyGetCollectionResponse + ShopifyCollectionResponse > = { id: 'shopify_get_collection', name: 'Shopify Get Collection', @@ -106,6 +69,7 @@ export const shopifyGetCollectionTool: ToolConfig< sortOrder updatedAt image { + id url altText } @@ -134,7 +98,7 @@ export const shopifyGetCollectionTool: ToolConfig< } `, variables: { - id: params.collectionId, + id: params.collectionId.trim(), productsFirst, }, } diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index cf0a7d01e60..b39f9ca56f9 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -84,13 +84,13 @@ export const shopifyGetInventoryLevelTool: ToolConfig< } `, variables: { - id: params.inventoryItemId, + id: params.inventoryItemId.trim(), }, } }, }, - transformResponse: async (response) => { + transformResponse: async (response, params) => { const data = await response.json() if (data.errors) { @@ -110,31 +110,45 @@ export const shopifyGetInventoryLevelTool: ToolConfig< } } - const inventoryLevels = inventoryItem.inventoryLevels.edges.map( - (edge: { - node: { - id: string - quantities: Array<{ name: string; quantity: number }> - location: { id: string; name: string } - } - }) => { - const node = edge.node - // Extract quantities into a more usable format - const quantitiesMap: Record = {} - node.quantities.forEach((q) => { - quantitiesMap[q.name] = q.quantity - }) - return { - id: node.id, - available: quantitiesMap.available ?? 0, - onHand: quantitiesMap.on_hand ?? 0, - committed: quantitiesMap.committed ?? 0, - incoming: quantitiesMap.incoming ?? 0, - reserved: quantitiesMap.reserved ?? 0, - location: node.location, + const requestedLocationId = params?.locationId?.trim() + const inventoryLevels = inventoryItem.inventoryLevels.edges + .map( + (edge: { + node: { + id: string + quantities: Array<{ name: string; quantity: number }> + location: { id: string; name: string } + } + }) => { + const node = edge.node + // Extract quantities into a more usable format + const quantitiesMap: Record = {} + node.quantities.forEach((q) => { + quantitiesMap[q.name] = q.quantity + }) + return { + id: node.id, + available: quantitiesMap.available ?? 0, + onHand: quantitiesMap.on_hand ?? 0, + committed: quantitiesMap.committed ?? 0, + incoming: quantitiesMap.incoming ?? 0, + reserved: quantitiesMap.reserved ?? 0, + location: node.location, + } } + ) + .filter( + (level: { location: { id: string } }) => + !requestedLocationId || level.location.id === requestedLocationId + ) + + if (requestedLocationId && inventoryLevels.length === 0) { + return { + success: false, + error: 'No inventory level found for the provided location', + output: {}, } - ) + } return { success: true, diff --git a/apps/sim/tools/shopify/index.ts b/apps/sim/tools/shopify/index.ts index 438199559e6..5d0e541a8fd 100644 --- a/apps/sim/tools/shopify/index.ts +++ b/apps/sim/tools/shopify/index.ts @@ -24,6 +24,7 @@ export { shopifyListInventoryItemsTool } from './list_inventory_items' export { shopifyListLocationsTool } from './list_locations' export { shopifyListOrdersTool } from './list_orders' export { shopifyListProductsTool } from './list_products' +export * from './types' export { shopifyUpdateCustomerTool } from './update_customer' export { shopifyUpdateOrderTool } from './update_order' export { shopifyUpdateProductTool } from './update_product' diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index 55a791cc502..e50ec507753 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -1,34 +1,9 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { + ShopifyCollectionsResponse, + ShopifyListCollectionsParams, +} from '@/tools/shopify/types' import { COLLECTION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListCollectionsParams extends ShopifyBaseParams { - first?: number - query?: string -} - -interface ShopifyCollectionsResponse extends ToolResponse { - output: { - collections?: Array<{ - id: string - title: string - handle: string - description: string | null - descriptionHtml: string | null - productsCount: number - sortOrder: string - updatedAt: string - image: { - url: string - altText: string | null - } | null - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListCollectionsTool: ToolConfig< ShopifyListCollectionsParams, @@ -100,6 +75,7 @@ export const shopifyListCollectionsTool: ToolConfig< sortOrder updatedAt image { + id url altText } @@ -151,7 +127,7 @@ export const shopifyListCollectionsTool: ToolConfig< productsCount: { count: number } sortOrder: string updatedAt: string - image: { url: string; altText: string | null } | null + image: { id: string; url: string; altText: string | null } | null } }) => ({ id: edge.node.id, diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 9e4a33dc004..97c3992a733 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -1,46 +1,10 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' import { INVENTORY_ITEM_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES, + type ShopifyInventoryItemsResponse, + type ShopifyListInventoryItemsParams, } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListInventoryItemsParams extends ShopifyBaseParams { - first?: number - query?: string -} - -interface ShopifyInventoryItemsResponse extends ToolResponse { - output: { - inventoryItems?: Array<{ - id: string - sku: string | null - tracked: boolean - createdAt: string - updatedAt: string - variant?: { - id: string - title: string - product?: { - id: string - title: string - } - } - inventoryLevels: Array<{ - id: string - available: number - location: { - id: string - name: string - } - }> - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListInventoryItemsTool: ToolConfig< ShopifyListInventoryItemsParams, diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index dd12e9b3ff8..6d6e675f5be 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -1,37 +1,6 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { ShopifyListLocationsParams, ShopifyLocationsResponse } from '@/tools/shopify/types' import { LOCATION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListLocationsParams extends ShopifyBaseParams { - first?: number - includeInactive?: boolean -} - -interface ShopifyLocationsResponse extends ToolResponse { - output: { - locations?: Array<{ - id: string - name: string - isActive: boolean - fulfillsOnlineOrders: boolean - address: { - address1: string | null - address2: string | null - city: string | null - province: string | null - provinceCode: string | null - country: string | null - countryCode: string | null - zip: string | null - phone: string | null - } | null - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListLocationsTool: ToolConfig< ShopifyListLocationsParams, diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts index 986384fe00c..66471b6a59b 100644 --- a/apps/sim/tools/shopify/types.ts +++ b/apps/sim/tools/shopify/types.ts @@ -64,6 +64,11 @@ const IMAGE_PROPERTIES = { altText: { type: 'string', description: 'Alternative text for accessibility', optional: true }, } as const satisfies Record +const FEATURED_IMAGE_OUTPUT_PROPERTIES = { + url: { type: 'string', description: 'Featured image URL' }, + altText: { type: 'string', description: 'Alternative text for accessibility', optional: true }, +} as const satisfies Record + /** Tracking info properties from Shopify FulfillmentTrackingInfo object */ const TRACKING_INFO_PROPERTIES = { company: { type: 'string', description: 'Shipping carrier name', optional: true }, @@ -156,6 +161,7 @@ export const CUSTOMER_OUTPUT_PROPERTIES = { type: 'object', properties: ADDRESS_PROPERTIES, }, + optional: true, }, defaultAddress: { type: 'object', @@ -245,16 +251,19 @@ export const ORDER_OUTPUT_PROPERTIES = { type: 'object', description: 'Order subtotal (before shipping and taxes)', properties: MONEY_BAG_PROPERTIES, + optional: true, }, totalTaxSet: { type: 'object', description: 'Total tax amount', properties: MONEY_BAG_PROPERTIES, + optional: true, }, totalShippingPriceSet: { type: 'object', description: 'Total shipping price', properties: MONEY_BAG_PROPERTIES, + optional: true, }, note: { type: 'string', description: 'Order note', optional: true }, tags: { @@ -287,6 +296,7 @@ export const ORDER_OUTPUT_PROPERTIES = { }, }, }, + optional: true, }, shippingAddress: { type: 'object', @@ -307,6 +317,7 @@ export const ORDER_OUTPUT_PROPERTIES = { type: 'object', properties: FULFILLMENT_PROPERTIES, }, + optional: true, }, } as const satisfies Record @@ -401,7 +412,7 @@ export const COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES = { featuredImage: { type: 'object', description: 'Featured product image', - properties: IMAGE_PROPERTIES, + properties: FEATURED_IMAGE_OUTPUT_PROPERTIES, optional: true, }, }, @@ -770,10 +781,12 @@ export interface ShopifyUpdateOrderParams extends ShopifyBaseParams { export interface ShopifyCancelOrderParams extends ShopifyBaseParams { orderId: string - reason: 'CUSTOMER' | 'FRAUD' | 'INVENTORY' | 'DECLINED' | 'OTHER' + reason: 'CUSTOMER' | 'DECLINED' | 'FRAUD' | 'INVENTORY' | 'OTHER' | 'STAFF' + restock: boolean notifyCustomer?: boolean - refund?: boolean - restock?: boolean + refundMethod?: { + originalPaymentMethodsRefund?: boolean + } staffNote?: string } @@ -839,14 +852,33 @@ export interface ShopifySetInventoryParams extends ShopifyBaseParams { // Fulfillment Tool Params export interface ShopifyCreateFulfillmentParams extends ShopifyBaseParams { - orderId: string - lineItemIds?: string[] + fulfillmentOrderId: string trackingNumber?: string trackingCompany?: string trackingUrl?: string notifyCustomer?: boolean } +export interface ShopifyListInventoryItemsParams extends ShopifyBaseParams { + first?: number + query?: string +} + +export interface ShopifyListLocationsParams extends ShopifyBaseParams { + first?: number + includeInactive?: boolean +} + +export interface ShopifyListCollectionsParams extends ShopifyBaseParams { + first?: number + query?: string +} + +export interface ShopifyGetCollectionParams extends ShopifyBaseParams { + collectionId: string + productsFirst?: number +} + // Tool Response Types export interface ShopifyProductResponse extends ToolResponse { output: { @@ -870,6 +902,16 @@ export interface ShopifyOrderResponse extends ToolResponse { } } +export interface ShopifyCancelOrderResponse extends ToolResponse { + output: { + order?: { + id: string + cancelled: boolean + message: string + } + } +} + export interface ShopifyOrdersResponse extends ToolResponse { output: { orders?: ShopifyOrder[] @@ -902,9 +944,156 @@ export interface ShopifyInventoryResponse extends ToolResponse { } } +export interface ShopifyInventoryAdjustmentResponse extends ToolResponse { + output: { + inventoryLevel?: { + adjustmentGroup: { + createdAt: string + reason: string + } + changes: Array<{ + name: string + delta: number + quantityAfterChange: number + item: { + id: string + sku: string | null + } + location: { + id: string + name: string + } + }> + } + } +} + export interface ShopifyFulfillmentResponse extends ToolResponse { output: { - fulfillment?: ShopifyFulfillment + fulfillment?: ShopifyFulfillment & { + fulfillmentLineItems: Array<{ + id: string + quantity: number + lineItem: { + title: string + } + }> + } + } +} + +export interface ShopifyInventoryItemsResponse extends ToolResponse { + output: { + inventoryItems?: Array<{ + id: string + sku: string | null + tracked: boolean + createdAt: string + updatedAt: string + variant?: { + id: string + title: string + product?: { + id: string + title: string + } + } + inventoryLevels: Array<{ + id: string + available: number + location: { + id: string + name: string + } + }> + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyLocationsResponse extends ToolResponse { + output: { + locations?: Array<{ + id: string + name: string + isActive: boolean + fulfillsOnlineOrders: boolean + address: { + address1: string | null + address2: string | null + city: string | null + province: string | null + provinceCode: string | null + country: string | null + countryCode: string | null + zip: string | null + phone: string | null + } | null + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyCollectionsResponse extends ToolResponse { + output: { + collections?: Array<{ + id: string + title: string + handle: string + description: string | null + descriptionHtml: string | null + productsCount: number + sortOrder: string + updatedAt: string + image: { + id: string + url: string + altText: string | null + } | null + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyCollectionResponse extends ToolResponse { + output: { + collection?: { + id: string + title: string + handle: string + description: string | null + descriptionHtml: string | null + productsCount: number + sortOrder: string + updatedAt: string + image: { + id: string + url: string + altText: string | null + } | null + products: Array<{ + id: string + title: string + handle: string + status: string + vendor: string + productType: string + totalInventory: number + featuredImage: { + url: string + altText: string | null + } | null + }> + } } } diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index 8554158bad7..caa2adbf930 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -110,8 +110,8 @@ export const shopifyUpdateProductTool: ToolConfig< return { query: ` - mutation productUpdate($input: ProductInput!) { - productUpdate(input: $input) { + mutation productUpdate($product: ProductUpdateInput!) { + productUpdate(product: $product) { product { id title @@ -153,7 +153,7 @@ export const shopifyUpdateProductTool: ToolConfig< } `, variables: { - input, + product: input, }, } }, diff --git a/apps/sim/tools/trello/add_comment.ts b/apps/sim/tools/trello/add_comment.ts index efd9aefa23e..2fa35b5391d 100644 --- a/apps/sim/tools/trello/add_comment.ts +++ b/apps/sim/tools/trello/add_comment.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloComment, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloAddCommentParams, TrelloAddCommentResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -39,56 +44,150 @@ export const trelloAddCommentTool: ToolConfig ({ - 'Content-Type': 'application/json', - Accept: 'application/json', - }), - body: (params) => { if (!params.text) { throw new Error('Comment text is required') } + const apiKey = env.TRELLO_API_KEY - return { - text: params.text, + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/actions/comments`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('text', params.text) + + return url.toString() }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), }, transformResponse: async (response) => { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add comment') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to add comment', + error, }, - error: data?.message || 'Failed to add comment', + error, } } - return { - success: true, - output: { - comment: { - id: data.id, - text: data.data?.text, - date: data.date, - memberCreator: data.memberCreator, + try { + const comment = mapTrelloComment(data) + + return { + success: true, + output: { + comment, }, - }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse created comment' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { comment: { - type: 'object', - description: 'The created comment object with id, text, date, and member creator', + type: 'json', + description: + 'Created comment action (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', + optional: true, + properties: { + id: { type: 'string', description: 'Action ID' }, + type: { type: 'string', description: 'Action type' }, + date: { type: 'string', description: 'Action timestamp' }, + idMemberCreator: { + type: 'string', + description: 'ID of the member who created the comment', + }, + text: { + type: 'string', + description: 'Comment text', + optional: true, + }, + memberCreator: { + type: 'object', + description: 'Member who created the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { + type: 'string', + description: 'Member full name', + optional: true, + }, + username: { + type: 'string', + description: 'Member username', + optional: true, + }, + }, + }, + card: { + type: 'object', + description: 'Card referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + shortLink: { + type: 'string', + description: 'Short card link', + optional: true, + }, + idShort: { + type: 'number', + description: 'Board-local card number', + optional: true, + }, + due: { + type: 'string', + description: 'Card due date', + optional: true, + }, + }, + }, + board: { + type: 'object', + description: 'Board referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + shortLink: { + type: 'string', + description: 'Short board link', + optional: true, + }, + }, + }, + list: { + type: 'object', + description: 'List referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + }, + }, + }, }, }, } diff --git a/apps/sim/tools/trello/create_card.ts b/apps/sim/tools/trello/create_card.ts index e552cab404b..50712859237 100644 --- a/apps/sim/tools/trello/create_card.ts +++ b/apps/sim/tools/trello/create_card.ts @@ -1,11 +1,16 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloCreateCardParams, TrelloCreateCardResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloCreateCardTool: ToolConfig = { id: 'trello_create_card', name: 'Trello Create Card', - description: 'Create a new card on a Trello board', + description: 'Create a new card in a Trello list', version: '1.0.0', oauth: { @@ -20,12 +25,6 @@ export const trelloCreateCardTool: ToolConfig { - const apiKey = env.TRELLO_API_KEY || '' - const token = params.accessToken - return `https://api.trello.com/1/cards?key=${apiKey}&token=${token}` + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() }, method: 'POST', headers: () => ({ @@ -83,45 +100,107 @@ export const trelloCreateCardTool: ToolConfig = { - idList: params.listId, + const body: Record = { + idList: params.listId.trim(), name: params.name, } if (params.desc) body.desc = params.desc if (params.pos) body.pos = params.pos if (params.due) body.due = params.due - if (params.labels) body.idLabels = params.labels + if (params.dueComplete !== undefined) body.dueComplete = params.dueComplete + if (params.labelIds?.length) body.idLabels = params.labelIds return body }, }, transformResponse: async (response) => { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to create card') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to create card', + error, }, - error: data?.message || 'Failed to create card', + error, } } - return { - success: true, - output: { - card: data, - }, + try { + const card = mapTrelloCard(data) + + return { + success: true, + output: { + card, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse created card' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { card: { - type: 'object', - description: 'The created card object with id, name, desc, url, and other properties', + type: 'json', + description: + 'Created card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, }, }, } diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts index 1067a8639c6..fd77dfbb9f9 100644 --- a/apps/sim/tools/trello/get_actions.ts +++ b/apps/sim/tools/trello/get_actions.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloAction, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloGetActionsParams, TrelloGetActionsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -42,7 +47,13 @@ export const trelloGetActionsTool: ToolConfig ({ @@ -78,33 +100,148 @@ export const trelloGetActionsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to get Trello actions') + + return { + success: false, + output: { + actions: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid action collection' + return { success: false, output: { actions: [], count: 0, - error: 'Invalid response from Trello API', + error, }, - error: 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - actions: data, - count: data.length, - }, + try { + const actions = data.map((item) => mapTrelloAction(item)) + + return { + success: true, + output: { + actions, + count: actions.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello actions' + + return { + success: false, + output: { + actions: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { actions: { type: 'array', - description: 'Array of action objects with type, date, member, and data', + description: + 'Action items (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Action ID' }, + type: { type: 'string', description: 'Action type' }, + date: { type: 'string', description: 'Action timestamp' }, + idMemberCreator: { + type: 'string', + description: 'ID of the member who created the action', + }, + text: { + type: 'string', + description: 'Comment text when present', + optional: true, + }, + memberCreator: { + type: 'object', + description: 'Member who created the action', + optional: true, + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { + type: 'string', + description: 'Member full name', + optional: true, + }, + username: { + type: 'string', + description: 'Member username', + optional: true, + }, + }, + }, + card: { + type: 'object', + description: 'Card referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + shortLink: { + type: 'string', + description: 'Short card link', + optional: true, + }, + idShort: { + type: 'number', + description: 'Board-local card number', + optional: true, + }, + due: { + type: 'string', + description: 'Card due date', + optional: true, + }, + }, + }, + board: { + type: 'object', + description: 'Board referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + shortLink: { + type: 'string', + description: 'Short board link', + optional: true, + }, + }, + }, + list: { + type: 'object', + description: 'List referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + }, + }, + }, + }, }, count: { type: 'number', description: 'Number of actions returned' }, }, diff --git a/apps/sim/tools/trello/index.ts b/apps/sim/tools/trello/index.ts index 1804235a176..e420abf3893 100644 --- a/apps/sim/tools/trello/index.ts +++ b/apps/sim/tools/trello/index.ts @@ -13,3 +13,5 @@ export { trelloGetActionsTool, trelloAddCommentTool, } + +export * from '@/tools/trello/types' diff --git a/apps/sim/tools/trello/list_cards.ts b/apps/sim/tools/trello/list_cards.ts index c8554611834..0f585420983 100644 --- a/apps/sim/tools/trello/list_cards.ts +++ b/apps/sim/tools/trello/list_cards.ts @@ -1,11 +1,16 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloListCardsParams, TrelloListCardsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloListCardsTool: ToolConfig = { id: 'trello_list_cards', name: 'Trello List Cards', - description: 'List all cards on a Trello board', + description: 'List cards from a Trello board or list', version: '1.0.0', oauth: { required: true, @@ -21,30 +26,42 @@ export const trelloListCardsTool: ToolConfig { - if (!params.boardId) { - throw new Error('Board ID is required') + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') } - const apiKey = env.TRELLO_API_KEY || '' - const token = params.accessToken - let url = `https://api.trello.com/1/boards/${params.boardId}/cards?key=${apiKey}&token=${token}&fields=id,name,desc,url,idBoard,idList,closed,labels,due,dueComplete` - if (params.listId) { - url += `&list=${params.listId}` + + if (params.boardId && params.listId) { + throw new Error('Provide either a board ID or list ID, not both') } - return url + + if (!params.listId && !params.boardId) { + throw new Error('Either a board ID or list ID is required') + } + + const path = params.listId + ? `/lists/${params.listId.trim()}/cards` + : `/boards/${params.boardId?.trim()}/cards` + const url = new URL(`${TRELLO_API_BASE_URL}${path}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() }, method: 'GET', headers: () => ({ @@ -53,34 +70,111 @@ export const trelloListCardsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list Trello cards') + + return { + success: false, + output: { + cards: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid card collection' + return { success: false, output: { cards: [], count: 0, - error: 'Invalid response from Trello API', + error, }, - error: 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - cards: data, - count: data.length, - }, + try { + const cards = data.map((item) => mapTrelloCard(item)) + + return { + success: true, + output: { + cards, + count: cards.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello cards' + + return { + success: false, + output: { + cards: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { cards: { type: 'array', - description: - 'Array of card objects with id, name, desc, url, board/list IDs, labels, and due date', + description: 'Cards returned from the selected Trello board or list', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, + }, }, count: { type: 'number', description: 'Number of cards returned' }, }, diff --git a/apps/sim/tools/trello/list_lists.ts b/apps/sim/tools/trello/list_lists.ts index cbf50c7dcdc..8911ca04cc9 100644 --- a/apps/sim/tools/trello/list_lists.ts +++ b/apps/sim/tools/trello/list_lists.ts @@ -1,10 +1,15 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloList, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloListListsParams, TrelloListListsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloListListsTool: ToolConfig = { id: 'trello_list_lists', - name: 'Trello List Lists', + name: 'Trello Get Lists', description: 'List all lists on a Trello board', version: '1.0.0', @@ -33,18 +38,21 @@ export const trelloListListsTool: ToolConfig ({ @@ -53,33 +61,75 @@ export const trelloListListsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list Trello lists') + + return { + success: false, + output: { + lists: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid list collection' + return { success: false, output: { lists: [], count: 0, - error: data?.message || data?.error || 'Invalid response from Trello API', + error, }, - error: data?.message || data?.error || 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - lists: data, - count: data.length, - }, + try { + const lists = data.map((item) => mapTrelloList(item)) + + return { + success: true, + output: { + lists, + count: lists.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello lists' + + return { + success: false, + output: { + lists: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { lists: { type: 'array', - description: 'Array of list objects with id, name, closed, pos, and idBoard', + description: 'Lists on the selected board', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + closed: { type: 'boolean', description: 'Whether the list is archived' }, + pos: { type: 'number', description: 'List position on the board' }, + idBoard: { type: 'string', description: 'Board ID containing the list' }, + }, + }, }, count: { type: 'number', description: 'Number of lists returned' }, }, diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts new file mode 100644 index 00000000000..e99e83d73b1 --- /dev/null +++ b/apps/sim/tools/trello/shared.ts @@ -0,0 +1,236 @@ +import type { + TrelloAction, + TrelloActionBoardTarget, + TrelloActionCardTarget, + TrelloActionListTarget, + TrelloCard, + TrelloComment, + TrelloLabel, + TrelloList, + TrelloMember, +} from '@/tools/trello/types' + +type TrelloRecord = Record + +export const TRELLO_API_BASE_URL = 'https://api.trello.com/1' + +function isRecord(value: unknown): value is TrelloRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getRequiredString(value: unknown, field: string): string { + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + + throw new Error(`Trello response is missing required field: ${field}`) +} + +function getOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function getOptionalBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function getNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return 0 +} + +function getOptionalNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + return null +} + +function getIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value.flatMap((item) => { + if (typeof item === 'string' && item.trim().length > 0) { + return [item] + } + + if (isRecord(item) && typeof item.id === 'string' && item.id.trim().length > 0) { + return [item.id] + } + + return [] + }) +} + +function mapTrelloLabel(value: unknown): TrelloLabel | null { + if (!isRecord(value) || typeof value.id !== 'string') { + return null + } + + return { + id: value.id, + name: typeof value.name === 'string' ? value.name : '', + color: getOptionalString(value.color), + } +} + +function mapTrelloMember(value: unknown): TrelloMember | null { + if (!isRecord(value) || typeof value.id !== 'string') { + return null + } + + return { + id: value.id, + fullName: getOptionalString(value.fullName), + username: getOptionalString(value.username), + } +} + +function mapActionCardTarget(value: unknown): TrelloActionCardTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + shortLink: getOptionalString(value.shortLink), + idShort: getOptionalNumber(value.idShort), + due: getOptionalString(value.due), + } +} + +function mapActionBoardTarget(value: unknown): TrelloActionBoardTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + shortLink: getOptionalString(value.shortLink), + } +} + +function mapActionListTarget(value: unknown): TrelloActionListTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + } +} + +export function mapTrelloList(value: unknown): TrelloList { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid list object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + closed: typeof value.closed === 'boolean' ? value.closed : false, + pos: getNumber(value.pos), + idBoard: getRequiredString(value.idBoard, 'idBoard'), + } +} + +export function mapTrelloCard(value: unknown): TrelloCard { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid card object') + } + + const rawLabels = Array.isArray(value.labels) ? value.labels : [] + const labels = rawLabels + .map((label) => mapTrelloLabel(label)) + .filter((label): label is TrelloLabel => label !== null) + const labelIds = getIdArray(value.idLabels) + + if (labelIds.length === 0) { + labelIds.push(...rawLabels.filter((label): label is string => typeof label === 'string')) + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + desc: typeof value.desc === 'string' ? value.desc : '', + url: getRequiredString(value.url, 'url'), + idBoard: getRequiredString(value.idBoard, 'idBoard'), + idList: getRequiredString(value.idList, 'idList'), + closed: typeof value.closed === 'boolean' ? value.closed : false, + labelIds, + labels, + due: getOptionalString(value.due), + dueComplete: getOptionalBoolean(value.dueComplete), + } +} + +export function mapTrelloAction(value: unknown): TrelloAction { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid action object') + } + + const data = isRecord(value.data) ? value.data : null + + return { + id: getRequiredString(value.id, 'id'), + type: getRequiredString(value.type, 'type'), + date: getRequiredString(value.date, 'date'), + idMemberCreator: getRequiredString(value.idMemberCreator, 'idMemberCreator'), + text: data ? getOptionalString(data.text) : null, + memberCreator: mapTrelloMember(value.memberCreator), + card: data ? mapActionCardTarget(data.card) : null, + board: data ? mapActionBoardTarget(data.board) : null, + list: data ? mapActionListTarget(data.list) : null, + } +} + +export function mapTrelloComment(value: unknown): TrelloComment { + return mapTrelloAction(value) +} + +export function extractTrelloErrorMessage( + response: Response, + data: unknown, + fallback: string +): string { + const parts: string[] = [] + + if (isRecord(data)) { + const message = data.message + const error = data.error + + if (typeof message === 'string' && message.trim().length > 0) { + parts.push(message) + } + + if (typeof error === 'string' && error.trim().length > 0 && error !== message) { + parts.push(error) + } + } + + if (parts.length > 0) { + return `${fallback}: ${parts.join(' - ')}` + } + + if (response.statusText) { + return `${fallback}: ${response.status} ${response.statusText}` + } + + return fallback +} diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts index 92ec68f2e9a..37edaee5654 100644 --- a/apps/sim/tools/trello/types.ts +++ b/apps/sim/tools/trello/types.ts @@ -8,6 +8,18 @@ export interface TrelloBoard { closed: boolean } +export interface TrelloLabel { + id: string + name: string + color: string | null +} + +export interface TrelloMember { + id: string + fullName: string | null + username: string | null +} + export interface TrelloList { id: string name: string @@ -24,38 +36,45 @@ export interface TrelloCard { idBoard: string idList: string closed: boolean - labels: Array<{ - id: string - name: string - color: string - }> - due?: string - dueComplete?: boolean + labelIds: string[] + labels: TrelloLabel[] + due: string | null + dueComplete: boolean | null } -export interface TrelloAction { +export interface TrelloActionCardTarget { id: string - type: string - date: string - memberCreator: { - id: string - fullName: string - username: string - } - data: Record + name: string + shortLink: string | null + idShort: number | null + due: string | null } -export interface TrelloComment { +export interface TrelloActionBoardTarget { id: string - text: string + name: string + shortLink: string | null +} + +export interface TrelloActionListTarget { + id: string + name: string +} + +export interface TrelloAction { + id: string + type: string date: string - memberCreator: { - id: string - fullName: string - username: string - } + idMemberCreator: string + text: string | null + memberCreator: TrelloMember | null + card: TrelloActionCardTarget | null + board: TrelloActionBoardTarget | null + list: TrelloActionListTarget | null } +export interface TrelloComment extends TrelloAction {} + export interface TrelloListListsParams { accessToken: string boardId: string @@ -63,19 +82,19 @@ export interface TrelloListListsParams { export interface TrelloListCardsParams { accessToken: string - boardId: string + boardId?: string listId?: string } export interface TrelloCreateCardParams { accessToken: string - boardId: string listId: string name: string desc?: string pos?: string due?: string - labels?: string + dueComplete?: boolean + labelIds?: string[] } export interface TrelloUpdateCardParams { @@ -95,6 +114,7 @@ export interface TrelloGetActionsParams { cardId?: string filter?: string limit?: number + page?: number } export interface TrelloAddCommentParams { diff --git a/apps/sim/tools/trello/update_card.ts b/apps/sim/tools/trello/update_card.ts index e41dbc7b5da..3d17a76fd77 100644 --- a/apps/sim/tools/trello/update_card.ts +++ b/apps/sim/tools/trello/update_card.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloUpdateCardParams, TrelloUpdateCardResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -69,9 +74,17 @@ export const trelloUpdateCardTool: ToolConfig ({ @@ -79,12 +92,12 @@ export const trelloUpdateCardTool: ToolConfig { - const body: Record = {} + const body: Record = {} if (params.name !== undefined) body.name = params.name if (params.desc !== undefined) body.desc = params.desc if (params.closed !== undefined) body.closed = params.closed - if (params.idList !== undefined) body.idList = params.idList + if (params.idList !== undefined) body.idList = params.idList.trim() if (params.due !== undefined) body.due = params.due if (params.dueComplete !== undefined) body.dueComplete = params.dueComplete @@ -97,30 +110,91 @@ export const trelloUpdateCardTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update card') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to update card', + error, }, - error: data?.message || 'Failed to update card', + error, } } - return { - success: true, - output: { - card: data, - }, + try { + const card = mapTrelloCard(data) + + return { + success: true, + output: { + card, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse updated card' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { card: { - type: 'object', - description: 'The updated card object with id, name, desc, url, and other properties', + type: 'json', + description: + 'Updated card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, }, }, } diff --git a/apps/sim/tools/whatsapp/index.ts b/apps/sim/tools/whatsapp/index.ts index cde6dc68974..c596cad77bf 100644 --- a/apps/sim/tools/whatsapp/index.ts +++ b/apps/sim/tools/whatsapp/index.ts @@ -1,3 +1,2 @@ -import { sendMessageTool } from '@/tools/whatsapp/send_message' - -export const whatsappSendMessageTool = sendMessageTool +export { sendMessageTool as whatsappSendMessageTool } from '@/tools/whatsapp/send_message' +export * from '@/tools/whatsapp/types' diff --git a/apps/sim/tools/whatsapp/send_message.ts b/apps/sim/tools/whatsapp/send_message.ts index c643b88c750..fa8b3aabe29 100644 --- a/apps/sim/tools/whatsapp/send_message.ts +++ b/apps/sim/tools/whatsapp/send_message.ts @@ -1,10 +1,14 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppResponse, WhatsAppSendMessageParams } from '@/tools/whatsapp/types' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + export const sendMessageTool: ToolConfig = { id: 'whatsapp_send_message', - name: 'WhatsApp', - description: 'Send WhatsApp messages', + name: 'WhatsApp Send Message', + description: 'Send a text message through the WhatsApp Cloud API.', version: '1.0.0', params: { @@ -18,7 +22,7 @@ export const sendMessageTool: ToolConfig { @@ -47,12 +58,11 @@ export const sendMessageTool: ToolConfig { - // Check if required parameters exist if (!params.phoneNumber) { throw new Error('Phone number is required but was not provided') } @@ -61,34 +71,68 @@ export const sendMessageTool: ToolConfig = { + body: params.message, + } + + if (typeof params.previewUrl === 'boolean') { + text.preview_url = params.previewUrl + } return { messaging_product: 'whatsapp', recipient_type: 'individual', - to: formattedPhoneNumber, + to: params.phoneNumber.trim(), type: 'text', - text: { - body: params.message, - }, + text, } }, }, transformResponse: async (response: Response) => { - const data = await response.json() + const responseText = await response.text() + const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} + const data = isRecord(parsed) ? parsed : {} + const error = isRecord(data.error) ? data.error : undefined + + if (!response.ok) { + const errorMessage = + (typeof error?.message === 'string' ? error.message : undefined) || + (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || + (isRecord(error?.error_data) && typeof error.error_data.details === 'string' + ? error.error_data.details + : undefined) || + `WhatsApp API error (${response.status})` + throw new Error(errorMessage) + } + + const contacts = Array.isArray(data.contacts) + ? data.contacts.filter(isRecord).map((contact) => ({ + input: typeof contact.input === 'string' ? contact.input : '', + wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : null, + })) + : [] + const firstMessage = + Array.isArray(data.messages) && isRecord(data.messages[0]) ? data.messages[0] : undefined + const messageId = typeof firstMessage?.id === 'string' ? firstMessage.id : undefined + const messageStatus = + typeof firstMessage?.message_status === 'string' ? firstMessage.message_status : undefined + + if (!messageId) { + throw new Error('WhatsApp API response did not include a message ID') + } return { success: true, output: { success: true, - messageId: data.messages?.[0]?.id, - phoneNumber: '', - status: '', - timestamp: '', + messageId, + messageStatus, + messagingProduct: + typeof data.messaging_product === 'string' ? data.messaging_product : undefined, + inputPhoneNumber: contacts[0]?.input ?? null, + whatsappUserId: contacts[0]?.wa_id ?? null, + contacts, }, } }, @@ -96,8 +140,41 @@ export const sendMessageTool: ToolConfigMeta for Developers Apps page and navigate to the "Build with us" --> "App Events" section.', - 'If you don\'t have an app:
  • Create an app from scratch
  • Give it a name and select your workspace
', - 'Select your App, then navigate to WhatsApp > Configuration.', - 'Find the Webhooks section and click "Edit".', + 'Go to your Meta App Dashboard and open the app connected to your WhatsApp Business Platform setup. If you used the WhatsApp use case flow, the configuration page may be under Use cases > Customize > Configuration instead of WhatsApp > Configuration.', + 'If you do not already have an app, create one first and add the WhatsApp product before configuring webhooks.', + 'Click "Save Configuration" above before verifying the callback URL so Sim has an active WhatsApp webhook config for this path. If this workflow is already deployed and you change the verification token or app secret, redeploy before re-verifying in Meta.', + 'In WhatsApp > Configuration, find the Webhooks section and click Edit.', 'Paste the Webhook URL above into the "Callback URL" field.', 'Paste the Verification Token into the "Verify token" field.', + "Copy your app's App Secret from App Settings > Basic and paste it into the App Secret field above so Sim can validate POST signatures.", 'Click "Verify and save".', - 'Click "Manage" next to Webhook fields and subscribe to `messages`.', + 'Click Manage next to webhook fields and subscribe to messages. That field covers incoming messages and outbound message status updates.', ] .map( (instruction, index) => @@ -56,65 +68,79 @@ export const whatsappWebhookTrigger: TriggerConfig = { ], outputs: { + eventType: { + type: 'string', + description: 'Webhook classification such as incoming_message, message_status, or mixed', + }, messageId: { type: 'string', - description: 'Unique message identifier (wamid)', + description: 'First WhatsApp message identifier (wamid) found in the webhook batch', }, from: { type: 'string', - description: 'Phone number of the message sender (with country code)', + description: 'Sender phone number from the first incoming message in the batch', + }, + recipientId: { + type: 'string', + description: 'Recipient phone number from the first status update in the batch', }, phoneNumberId: { type: 'string', - description: 'WhatsApp Business phone number ID that received the message', + description: 'Business phone number ID from the first message or status item in the batch', + }, + displayPhoneNumber: { + type: 'string', + description: + 'Business display phone number from the first message or status item in the batch', }, text: { type: 'string', - description: 'Message text content (for text messages)', + description: 'Text body from the first incoming text message in the batch', }, timestamp: { type: 'string', - description: 'Message timestamp (Unix timestamp)', + description: 'Timestamp from the first message or status item in the batch', }, messageType: { + type: 'string', + description: 'Type of the first incoming message in the batch (text, image, system, etc.)', + }, + status: { type: 'string', description: - 'Type of message (text, image, audio, video, document, sticker, location, contacts)', + 'First outgoing message status in the batch, such as sent, delivered, read, or failed', }, contact: { - type: 'object', - description: 'Contact information of the sender', - properties: { - wa_id: { type: 'string', description: 'WhatsApp ID (phone number with country code)' }, - profile: { - type: 'object', - description: 'Contact profile', - properties: { - name: { type: 'string', description: 'Contact display name' }, - }, - }, - }, + type: 'json', + description: 'First sender contact in the batch (wa_id, profile.name)', + }, + webhookContacts: { + type: 'json', + description: 'All sender contact profiles from the webhook batch', + }, + messages: { + type: 'json', + description: + 'All incoming message objects from the webhook batch, flattened across entries/changes', + }, + statuses: { + type: 'json', + description: + 'All message status objects from the webhook batch, flattened across entries/changes', + }, + conversation: { + type: 'json', + description: + 'Conversation metadata from the first status update in the batch (id, expiration_timestamp, origin.type)', + }, + pricing: { + type: 'json', + description: + 'Pricing metadata from the first status update in the batch (billable, pricing_model, category)', }, raw: { - type: 'object', - description: 'Complete raw webhook payload from WhatsApp', - properties: { - object: { type: 'string', description: 'Always "whatsapp_business_account"' }, - entry: { - type: 'array', - description: 'Array of entry objects', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'WhatsApp Business Account ID' }, - changes: { - type: 'array', - description: 'Array of change objects', - }, - }, - }, - }, - }, + type: 'json', + description: 'Complete structured webhook payload from WhatsApp', }, },