mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(crowdstrike): add tools + validate whatsapp, shopify, trello (#4123)
* feat(crowdstrike): add tools + validate whatsapp, shopify, trello * address comment * remove tools when unsure about docs shape * addresss comments * fix build
This commit is contained in:
@@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CrowdStrikeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 768 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m152.8 23.6c-.8.8.3 4.4 1.3 4.4.5 0 .9.5.9 1.2 0 1.5 7.2 15.9 8.8 17.6.6.7 1.2 1.7 1.2 2.2 0 1.3 8.6 13.7 12.8 18.4 10 11.2 28.2 28.1 35.2 32.7 1.4.9 3.9 2.9 5.5 4.3 1.7 1.5 4.8 3.9 7 5.4s4.9 3.5 5.9 4.4c1.1 1 3.8 3 6 4.5 2.3 1.6 5 3.6 6 4.5 1.1 1 3.8 3 6 4.5 2.3 1.5 4.3 3 4.6 3.3s3.7 3 7.5 6c3.9 3 7.5 5.9 8.1 6.5.6.5 4.6 4.1 8.9 8 14.6 13.1 25.8 25.3 32.6 35.5 6.6 10 9.2 14.4 15.1 25.8 3.1 6.2 7.7 14.4 10 18.3 2.4 3.9 5.4 8.9 6.7 11.2s3 4.8 3.8 5.5c.7.7 1.3 1.8 1.3 2.3s.5 1.5 1 2.2c.6.7 5.3 7.7 10.6 15.7 16.9 25.6 40.1 46 62.9 55.1 10.8 4.3 33.4 6 63 4.7 20.6-.8 44.2-.2 48.3 1.3 1.3.5 4.2.9 6.5.9 2.3.1 6 .7 8.2 1.5s4.9 1.5 6 1.5 3.3.7 4.9 1.5c1.5.8 3.5 1.5 4.3 1.5 1.6 0 7.1 2.4 19.8 8.6 18.3 9.1 33.1 19.9 48.7 35.6 10.4 10.5 10.8 10.8 11.4 8.2.8-3.1-.2-13.7-1.5-16.1-.5-1-2-4.1-3.3-6.8-2.5-5.6-7.2-12.3-14.2-20.4-2.7-3.3-4.6-6.5-4.6-7.9 0-4.1-3.9-10.5-8.5-13.9-5.8-4.3-23.6-13.3-26.3-13.3-.5 0-2.3-.7-3.8-1.5-1.6-.8-3.7-1.5-4.7-1.5-.9 0-2.5-.4-3.5-.9-.9-.5-5.1-1.9-9.2-3.1-13.7-4.1-22.5-7.2-25.6-9.1-3.3-2-6.4-7.2-6.4-10.7 0-2.6 3.8-14.4 5-15.6.6-.6 1-1.7 1-2.5 0-.9.6-2.8 1.4-4.3.8-1.4 1.9-5.8 2.6-9.7 3.3-19.4-7.2-31.8-41-48.7-4.5-2.2-12.7-5.9-16.5-7.5-1.1-.4-4.1-1.7-6.7-2.8-2.6-1.2-5.4-2.1-6.2-2.1s-1.8-.5-2.1-1c-.3-.6-1.3-1-2.2-1-.8 0-2.9-.6-4.6-1.4-1.8-.8-10.4-3.8-19.2-6.6-8.8-2.9-16.7-5.6-17.6-6-.9-.5-3.4-1.2-5.5-1.6-2.2-.3-4.3-1-4.9-1.4-.5-.4-2.6-1.1-4.5-1.4-1.9-.4-4.4-1.1-5.5-1.6-1.1-.4-4-1.3-6.5-2-2.5-.6-6.3-1.6-8.5-2.1-2.2-.6-4.9-1.5-6-1.9-1.1-.5-3.6-1.2-5.5-1.6-1.9-.3-4.1-1-5-1.4-.8-.4-4.9-1.8-9-3s-8.2-2.5-9-2.9c-.9-.5-3.1-1.2-5-1.6s-3.9-1-4.5-1.4c-.5-.4-4.4-1.8-8.5-3.1-4.1-1.2-7.9-2.6-8.5-3-.5-.4-3.9-1.7-7.5-3s-6.9-2.7-7.4-3.2c-.6-.4-1.6-.8-2.4-.8-2 0-11.4-4.3-35.2-15.9-16.7-8.2-32.1-16.6-35.5-19.3-.5-.4-4.6-3.1-9-6s-8.4-5.6-9-6c-.5-.4-5.2-3.9-10.4-7.8-18.1-13.5-44.4-38.8-55.5-53.5-2.1-2.8-3.9-5.1-4-5.3-.2-.1-.5.1-.8.4zm447.2 303c10.2 3.4 13.5 6 15.9 12.1 2.4 5.9-1.6 7.3-6.5 2.2-1.6-1.7-4.5-4-6.4-5.2s-4.1-2.7-4.8-3.4-1.9-1.3-2.7-1.3c-1.3 0-2.5-2.1-2.5-4.6 0-1.8 1.4-1.8 7 .2zm-519-240c0 1.1 8.5 17.9 10 19.7.6.7 2.7 3.4 4.7 6.2 7.3 9.8 18.7 21.5 33.9 34.5 3.8 3.3 14.2 11.1 17.5 13.2 1.4.9 3.2 2.3 4 3 .8.8 3.2 2.5 5.4 3.8s4.2 2.7 4.5 3c.6.8 30.1 18.3 39.5 23.5 7.4 4.2 15.4 8.2 43.5 21.9 16.5 8.1 19.6 9.7 31.7 17 9.1 5.5 23.7 16.9 31 24.2 4.1 4.1 7.6 7.4 7.8 7.4.3 0-.1-1.1-.7-2.5s-1.5-2.5-2-2.5c-.4 0-.8-.6-.8-1.3 0-.8-.9-2.5-2-3.8s-2.3-2.9-2.7-3.4c-7.3-9.6-13.3-15.4-31.7-31-2.5-2.2-19-13.4-26.7-18.2-6.1-3.9-18.4-10.8-30.9-17.5-3-1.7-5.9-3.4-6.5-3.8-.9-.7-5.2-3-19.5-10.8-9-4.8-31.8-18.9-35.5-21.9-.5-.5-2.8-2-5-3.3s-4.4-2.8-5-3.2c-.5-.4-5.9-4.4-12-8.9-6-4.5-11.2-8.5-11.5-8.8-.3-.4-2.7-2.4-5.5-4.5-5.6-4.2-12.8-10.8-26.2-24-5.1-5-9.3-8.6-9.3-8zm113.6 179.1c-1 1 15.8 16.6 26.9 24.9 5.5 4.1 10.5 7.8 11 8.2 2.6 2 11.6 7.2 12.4 7.2.5 0 1.6.6 2.3 1.2.7.7 2.9 2 4.8 3 13.3 6.3 19 8.8 20.4 8.8.8 0 1.7.4 2 .8.8 1.3 32.3 11.2 35.8 11.2 1 0 2.6.4 3.6 1 .9.5 3.7 1.4 6.2 1.9 8.7 1.9 13.5 3.1 15.5 4 1.1.5 5.4 1.9 9.5 3.2s7.9 2.6 8.5 3.1c.5.4 1.5.8 2.3.8s2.8.6 4.5 1.4c16.4 7.1 20.8 8.8 21.4 8.3.3-.4-.7-1.7-2.3-2.9-2.5-2-6.9-5.9-16.4-14.8-1.5-1.4-4.2-3.8-6-5.4-5-4.3-26-19.9-30.5-22.6-2.2-1.3-4.2-2.7-4.5-3-.3-.4-1.2-1-2-1.4s-4.2-2.2-7.5-4.1c-6.2-3.6-18.9-9.9-26-12.9-2.2-.9-4.7-2.1-5.5-2.5-.9-.5-3-1.2-4.8-1.5-1.7-.4-3.4-1.2-3.7-1.7-.4-.5-1.6-.9-2.8-.9-2.2.1-2.2.1-.2 1.2 1.1.6 2.2 1.4 2.5 1.8.3.3 2.5 1.8 5 3.3 5.3 3.1 15 11.7 15 13.3 0 .6-.7 1.7-1.5 2.4-1.2 1-4.1.9-14.5-.4-7.2-.9-14.1-2.1-15.3-2.6-1.2-.4-4.7-1.6-7.7-2.5-15.6-4.7-47-22.1-56.1-31-.9-.8-1.9-1.2-2.3-.8z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
ConfluenceIcon,
|
||||
CrowdStrikeIcon,
|
||||
CursorIcon,
|
||||
DagsterIcon,
|
||||
DatabricksIcon,
|
||||
@@ -220,6 +221,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
crowdstrike: CrowdStrikeIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
dagster: DagsterIcon,
|
||||
databricks: DatabricksIcon,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: CrowdStrike
|
||||
description: Query CrowdStrike Identity Protection sensors and documented aggregates
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="crowdstrike"
|
||||
color="#E01F3D"
|
||||
/>
|
||||
|
||||
## 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 |
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"cloudformation",
|
||||
"cloudwatch",
|
||||
"confluence",
|
||||
"crowdstrike",
|
||||
"cursor",
|
||||
"dagster",
|
||||
"databricks",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
ConfluenceIcon,
|
||||
CrowdStrikeIcon,
|
||||
CursorIcon,
|
||||
DagsterIcon,
|
||||
DatabricksIcon,
|
||||
@@ -220,6 +221,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
crowdstrike: CrowdStrikeIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
dagster: DagsterIcon,
|
||||
databricks: DatabricksIcon,
|
||||
|
||||
@@ -2345,6 +2345,37 @@
|
||||
"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",
|
||||
"slug": "cursor",
|
||||
@@ -11963,8 +11994,8 @@
|
||||
"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",
|
||||
@@ -12611,7 +12642,7 @@
|
||||
{
|
||||
"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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
|
||||
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<CrowdStrikeAggregateQuery> = 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<typeof BaseRequestSchema>
|
||||
type CrowdStrikeQuerySensorsRequest = z.infer<typeof QuerySensorsSchema>
|
||||
|
||||
function getCloudBaseUrl(cloud: CrowdStrikeCloud): string {
|
||||
const cloudMap: Record<CrowdStrikeCloud, string> = {
|
||||
'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<string> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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) {
|
||||
|
||||
@@ -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<CrowdStrikeResponse> = {
|
||||
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<string, unknown> = {
|
||||
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' },
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<ShopifyResponse> = {
|
||||
type: 'shopify',
|
||||
name: 'Shopify',
|
||||
@@ -84,7 +94,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
{ 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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
{
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
return params.operation || 'shopify_list_products'
|
||||
},
|
||||
params: (params) => {
|
||||
const first = parseOptionalNumberInput(params.first, 'first')
|
||||
const baseParams: Record<string, unknown> = {
|
||||
oauthCredential: params.oauthCredential,
|
||||
shopDomain: params.shopDomain?.trim(),
|
||||
@@ -569,6 +675,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
case 'shopify_list_products':
|
||||
return {
|
||||
...baseParams,
|
||||
first,
|
||||
query: params.productQuery?.trim(),
|
||||
}
|
||||
|
||||
@@ -612,7 +719,9 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
...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<ShopifyResponse> = {
|
||||
?.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean),
|
||||
acceptsMarketing: params.acceptsMarketing,
|
||||
}
|
||||
|
||||
case 'shopify_get_customer':
|
||||
@@ -673,6 +787,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
case 'shopify_list_customers':
|
||||
return {
|
||||
...baseParams,
|
||||
first,
|
||||
query: params.customerQuery?.trim(),
|
||||
}
|
||||
|
||||
@@ -707,6 +822,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
case 'shopify_list_inventory_items':
|
||||
return {
|
||||
...baseParams,
|
||||
first,
|
||||
query: params.inventoryQuery?.trim(),
|
||||
}
|
||||
|
||||
@@ -741,6 +857,8 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
case 'shopify_list_locations':
|
||||
return {
|
||||
...baseParams,
|
||||
first,
|
||||
includeInactive: parseOptionalBooleanInput(params.includeInactive),
|
||||
}
|
||||
|
||||
// Fulfillment Operations
|
||||
@@ -754,13 +872,14 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
return {
|
||||
...baseParams,
|
||||
collectionId: params.collectionId.trim(),
|
||||
productsFirst: parseOptionalNumberInput(params.productsFirst, 'productsFirst'),
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -791,14 +911,22 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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<ShopifyResponse> = {
|
||||
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
|
||||
|
||||
+349
-277
@@ -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<ToolResponse> = {
|
||||
export const TrelloBlock: BlockConfig<TrelloResponse> = {
|
||||
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<ToolResponse> = {
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'boardSelector',
|
||||
title: 'Board',
|
||||
@@ -74,241 +113,183 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
|
||||
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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'listId',
|
||||
title: 'List',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter list ID or search for a list',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_card',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'name',
|
||||
title: 'Card Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter card name/title',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_card',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'desc',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter card description (optional)',
|
||||
condition: {
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_card',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'pos',
|
||||
title: 'Position',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Top', id: 'top' },
|
||||
{ label: 'Bottom', id: 'bottom' },
|
||||
],
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_card',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'due',
|
||||
title: 'Due Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD or ISO 8601',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_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: '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',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_update_card',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'name',
|
||||
title: 'New Card Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter new card name (leave empty to keep current)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_update_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',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'closed',
|
||||
title: 'Archive Card',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_update_card',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dueComplete',
|
||||
title: 'Mark Due Date Complete',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_update_card',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'idList',
|
||||
title: 'Move to List',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter list ID to move card',
|
||||
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',
|
||||
placeholder: 'Enter Trello card ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_get_actions',
|
||||
value: ['trello_update_card', 'trello_get_actions', 'trello_add_comment'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['trello_update_card', 'trello_add_comment'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
title: 'Card Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter card name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['trello_create_card', 'trello_update_card'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_card',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'desc',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter card description',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['trello_create_card', 'trello_update_card'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pos',
|
||||
title: 'Position',
|
||||
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 timestamp',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
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.
|
||||
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 extra text.`,
|
||||
placeholder: 'Describe the due date (e.g. "next Friday", "in 2 weeks")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
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_create_card', 'trello_update_card'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'labelIds',
|
||||
title: 'Label IDs',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated label IDs',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_create_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 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 ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Trello list ID',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'trello_update_card',
|
||||
},
|
||||
},
|
||||
{
|
||||
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 result: Record<string, any> = { ...rest }
|
||||
|
||||
if (limit && operation === 'trello_get_actions') {
|
||||
result.limit = Number.parseInt(limit, 10)
|
||||
const operation = getTrimmedString(params.operation) ?? 'trello_list_lists'
|
||||
const baseParams: Record<string, unknown> = {
|
||||
oauthCredential: params.oauthCredential,
|
||||
}
|
||||
|
||||
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)
|
||||
switch (operation) {
|
||||
case 'trello_list_lists': {
|
||||
const boardId = getTrimmedString(params.boardId)
|
||||
|
||||
if (!boardId) {
|
||||
throw new Error('Board ID is required.')
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ export const WhatsAppBlock: BlockConfig<WhatsAppResponse> = {
|
||||
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<WhatsAppResponse> = {
|
||||
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,
|
||||
|
||||
@@ -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<string, BlockConfig> = {
|
||||
cloudflare: CloudflareBlock,
|
||||
cloudformation: CloudFormationBlock,
|
||||
cloudwatch: CloudWatchBlock,
|
||||
crowdstrike: CrowdStrikeBlock,
|
||||
clay: ClayBlock,
|
||||
clerk: ClerkBlock,
|
||||
condition: ConditionBlock,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,6 +361,123 @@ export function createVersionedToolSelector<TParams extends Record<string, any>>
|
||||
}
|
||||
}
|
||||
|
||||
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<T = unknown>(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 <block.files[0]> to select one file.'
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CrowdStrikeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 768 500' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='m152.8 23.6c-.8.8.3 4.4 1.3 4.4.5 0 .9.5.9 1.2 0 1.5 7.2 15.9 8.8 17.6.6.7 1.2 1.7 1.2 2.2 0 1.3 8.6 13.7 12.8 18.4 10 11.2 28.2 28.1 35.2 32.7 1.4.9 3.9 2.9 5.5 4.3 1.7 1.5 4.8 3.9 7 5.4s4.9 3.5 5.9 4.4c1.1 1 3.8 3 6 4.5 2.3 1.6 5 3.6 6 4.5 1.1 1 3.8 3 6 4.5 2.3 1.5 4.3 3 4.6 3.3s3.7 3 7.5 6c3.9 3 7.5 5.9 8.1 6.5.6.5 4.6 4.1 8.9 8 14.6 13.1 25.8 25.3 32.6 35.5 6.6 10 9.2 14.4 15.1 25.8 3.1 6.2 7.7 14.4 10 18.3 2.4 3.9 5.4 8.9 6.7 11.2s3 4.8 3.8 5.5c.7.7 1.3 1.8 1.3 2.3s.5 1.5 1 2.2c.6.7 5.3 7.7 10.6 15.7 16.9 25.6 40.1 46 62.9 55.1 10.8 4.3 33.4 6 63 4.7 20.6-.8 44.2-.2 48.3 1.3 1.3.5 4.2.9 6.5.9 2.3.1 6 .7 8.2 1.5s4.9 1.5 6 1.5 3.3.7 4.9 1.5c1.5.8 3.5 1.5 4.3 1.5 1.6 0 7.1 2.4 19.8 8.6 18.3 9.1 33.1 19.9 48.7 35.6 10.4 10.5 10.8 10.8 11.4 8.2.8-3.1-.2-13.7-1.5-16.1-.5-1-2-4.1-3.3-6.8-2.5-5.6-7.2-12.3-14.2-20.4-2.7-3.3-4.6-6.5-4.6-7.9 0-4.1-3.9-10.5-8.5-13.9-5.8-4.3-23.6-13.3-26.3-13.3-.5 0-2.3-.7-3.8-1.5-1.6-.8-3.7-1.5-4.7-1.5-.9 0-2.5-.4-3.5-.9-.9-.5-5.1-1.9-9.2-3.1-13.7-4.1-22.5-7.2-25.6-9.1-3.3-2-6.4-7.2-6.4-10.7 0-2.6 3.8-14.4 5-15.6.6-.6 1-1.7 1-2.5 0-.9.6-2.8 1.4-4.3.8-1.4 1.9-5.8 2.6-9.7 3.3-19.4-7.2-31.8-41-48.7-4.5-2.2-12.7-5.9-16.5-7.5-1.1-.4-4.1-1.7-6.7-2.8-2.6-1.2-5.4-2.1-6.2-2.1s-1.8-.5-2.1-1c-.3-.6-1.3-1-2.2-1-.8 0-2.9-.6-4.6-1.4-1.8-.8-10.4-3.8-19.2-6.6-8.8-2.9-16.7-5.6-17.6-6-.9-.5-3.4-1.2-5.5-1.6-2.2-.3-4.3-1-4.9-1.4-.5-.4-2.6-1.1-4.5-1.4-1.9-.4-4.4-1.1-5.5-1.6-1.1-.4-4-1.3-6.5-2-2.5-.6-6.3-1.6-8.5-2.1-2.2-.6-4.9-1.5-6-1.9-1.1-.5-3.6-1.2-5.5-1.6-1.9-.3-4.1-1-5-1.4-.8-.4-4.9-1.8-9-3s-8.2-2.5-9-2.9c-.9-.5-3.1-1.2-5-1.6s-3.9-1-4.5-1.4c-.5-.4-4.4-1.8-8.5-3.1-4.1-1.2-7.9-2.6-8.5-3-.5-.4-3.9-1.7-7.5-3s-6.9-2.7-7.4-3.2c-.6-.4-1.6-.8-2.4-.8-2 0-11.4-4.3-35.2-15.9-16.7-8.2-32.1-16.6-35.5-19.3-.5-.4-4.6-3.1-9-6s-8.4-5.6-9-6c-.5-.4-5.2-3.9-10.4-7.8-18.1-13.5-44.4-38.8-55.5-53.5-2.1-2.8-3.9-5.1-4-5.3-.2-.1-.5.1-.8.4zm447.2 303c10.2 3.4 13.5 6 15.9 12.1 2.4 5.9-1.6 7.3-6.5 2.2-1.6-1.7-4.5-4-6.4-5.2s-4.1-2.7-4.8-3.4-1.9-1.3-2.7-1.3c-1.3 0-2.5-2.1-2.5-4.6 0-1.8 1.4-1.8 7 .2zm-519-240c0 1.1 8.5 17.9 10 19.7.6.7 2.7 3.4 4.7 6.2 7.3 9.8 18.7 21.5 33.9 34.5 3.8 3.3 14.2 11.1 17.5 13.2 1.4.9 3.2 2.3 4 3 .8.8 3.2 2.5 5.4 3.8s4.2 2.7 4.5 3c.6.8 30.1 18.3 39.5 23.5 7.4 4.2 15.4 8.2 43.5 21.9 16.5 8.1 19.6 9.7 31.7 17 9.1 5.5 23.7 16.9 31 24.2 4.1 4.1 7.6 7.4 7.8 7.4.3 0-.1-1.1-.7-2.5s-1.5-2.5-2-2.5c-.4 0-.8-.6-.8-1.3 0-.8-.9-2.5-2-3.8s-2.3-2.9-2.7-3.4c-7.3-9.6-13.3-15.4-31.7-31-2.5-2.2-19-13.4-26.7-18.2-6.1-3.9-18.4-10.8-30.9-17.5-3-1.7-5.9-3.4-6.5-3.8-.9-.7-5.2-3-19.5-10.8-9-4.8-31.8-18.9-35.5-21.9-.5-.5-2.8-2-5-3.3s-4.4-2.8-5-3.2c-.5-.4-5.9-4.4-12-8.9-6-4.5-11.2-8.5-11.5-8.8-.3-.4-2.7-2.4-5.5-4.5-5.6-4.2-12.8-10.8-26.2-24-5.1-5-9.3-8.6-9.3-8zm113.6 179.1c-1 1 15.8 16.6 26.9 24.9 5.5 4.1 10.5 7.8 11 8.2 2.6 2 11.6 7.2 12.4 7.2.5 0 1.6.6 2.3 1.2.7.7 2.9 2 4.8 3 13.3 6.3 19 8.8 20.4 8.8.8 0 1.7.4 2 .8.8 1.3 32.3 11.2 35.8 11.2 1 0 2.6.4 3.6 1 .9.5 3.7 1.4 6.2 1.9 8.7 1.9 13.5 3.1 15.5 4 1.1.5 5.4 1.9 9.5 3.2s7.9 2.6 8.5 3.1c.5.4 1.5.8 2.3.8s2.8.6 4.5 1.4c16.4 7.1 20.8 8.8 21.4 8.3.3-.4-.7-1.7-2.3-2.9-2.5-2-6.9-5.9-16.4-14.8-1.5-1.4-4.2-3.8-6-5.4-5-4.3-26-19.9-30.5-22.6-2.2-1.3-4.2-2.7-4.5-3-.3-.4-1.2-1-2-1.4s-4.2-2.2-7.5-4.1c-6.2-3.6-18.9-9.9-26-12.9-2.2-.9-4.7-2.1-5.5-2.5-.9-.5-3-1.2-4.8-1.5-1.7-.4-3.4-1.2-3.7-1.7-.4-.5-1.6-.9-2.8-.9-2.2.1-2.2.1-.2 1.2 1.1.6 2.2 1.4 2.5 1.8.3.3 2.5 1.8 5 3.3 5.3 3.1 15 11.7 15 13.3 0 .6-.7 1.7-1.5 2.4-1.2 1-4.1.9-14.5-.4-7.2-.9-14.1-2.1-15.3-2.6-1.2-.4-4.7-1.6-7.7-2.5-15.6-4.7-47-22.1-56.1-31-.9-.8-1.9-1.2-2.3-.8z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -261,8 +261,8 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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<string, string>): 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<string, unknown>
|
||||
|
||||
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' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getWhatsAppChanges(
|
||||
body: unknown
|
||||
): Array<{ field?: string; value: Record<string, unknown> }> {
|
||||
if (!isRecord(body) || !Array.isArray(body.entry)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const changes: Array<{ field?: string; value: Record<string, unknown> }> = []
|
||||
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
metadata?: Record<string, unknown>
|
||||
) {
|
||||
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<string, unknown>,
|
||||
metadata?: Record<string, unknown>
|
||||
) {
|
||||
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>): 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)
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const entry = b?.entry as Array<Record<string, unknown>> | undefined
|
||||
const changes = entry?.[0]?.changes as Array<Record<string, unknown>> | undefined
|
||||
const data = changes?.[0]?.value as Record<string, unknown> | undefined
|
||||
const messages = (data?.messages as Array<Record<string, unknown>>) || []
|
||||
extractIdempotencyId(body: unknown) {
|
||||
const keys = new Set<string>()
|
||||
|
||||
if (messages.length > 0) {
|
||||
const message = messages[0]
|
||||
const metadata = data?.metadata as Record<string, unknown> | undefined
|
||||
const text = message.text as Record<string, unknown> | undefined
|
||||
return {
|
||||
input: {
|
||||
messageId: message.id,
|
||||
from: message.from,
|
||||
phoneNumberId: metadata?.phone_number_id,
|
||||
text: text?.body,
|
||||
timestamp: message.timestamp,
|
||||
raw: JSON.stringify(message),
|
||||
},
|
||||
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 { input: null }
|
||||
|
||||
return buildWhatsAppIdempotencyKey(keys)
|
||||
},
|
||||
|
||||
formatSuccessResponse() {
|
||||
return new NextResponse(null, { status: 200 })
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
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<string, unknown>
|
||||
}> = []
|
||||
const statuses: Array<{
|
||||
messageId?: string
|
||||
recipientId?: string
|
||||
phoneNumberId?: string
|
||||
displayPhoneNumber?: string
|
||||
status?: string
|
||||
timestamp?: string
|
||||
conversation?: Record<string, unknown>
|
||||
pricing?: Record<string, unknown>
|
||||
raw: Record<string, unknown>
|
||||
}> = []
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
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' }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { crowdstrikeGetSensorAggregatesTool } from './get_sensor_aggregates'
|
||||
export { crowdstrikeGetSensorDetailsTool } from './get_sensor_details'
|
||||
export { crowdstrikeQuerySensorsTool } from './query_sensors'
|
||||
export * from './types'
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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<string, unknown> | 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
|
||||
@@ -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<string, ToolConfig> = {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<ShopifyCancelOrderParams, ShopifyOrderResponse> = {
|
||||
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<ShopifyCancelOrderParams, Shopif
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to notify the customer about the cancellation',
|
||||
},
|
||||
refund: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to refund the order',
|
||||
},
|
||||
restock: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to restock the inventory committed to the order',
|
||||
},
|
||||
refundMethod: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to restock the inventory',
|
||||
description:
|
||||
'Optional refund method object, for example {"originalPaymentMethodsRefund": true}',
|
||||
},
|
||||
staffNote: {
|
||||
type: 'string',
|
||||
@@ -78,11 +82,14 @@ export const shopifyCancelOrderTool: ToolConfig<ShopifyCancelOrderParams, Shopif
|
||||
if (!params.reason) {
|
||||
throw new Error('Cancellation reason is required')
|
||||
}
|
||||
if (typeof params.restock !== 'boolean') {
|
||||
throw new Error('Restock is required')
|
||||
}
|
||||
|
||||
return {
|
||||
query: `
|
||||
mutation orderCancel($orderId: ID!, $reason: OrderCancelReason!, $notifyCustomer: Boolean, $refund: Boolean!, $restock: Boolean!, $staffNote: String) {
|
||||
orderCancel(orderId: $orderId, reason: $reason, notifyCustomer: $notifyCustomer, refund: $refund, restock: $restock, staffNote: $staffNote) {
|
||||
mutation orderCancel($orderId: ID!, $reason: OrderCancelReason!, $notifyCustomer: Boolean, $refundMethod: OrderCancelRefundMethodInput, $restock: Boolean!, $staffNote: String) {
|
||||
orderCancel(orderId: $orderId, reason: $reason, notifyCustomer: $notifyCustomer, refundMethod: $refundMethod, restock: $restock, staffNote: $staffNote) {
|
||||
job {
|
||||
id
|
||||
done
|
||||
@@ -96,12 +103,12 @@ export const shopifyCancelOrderTool: ToolConfig<ShopifyCancelOrderParams, Shopif
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
orderId: params.orderId,
|
||||
orderId: params.orderId.trim(),
|
||||
reason: params.reason,
|
||||
notifyCustomer: params.notifyCustomer ?? false,
|
||||
refund: params.refund ?? false,
|
||||
restock: params.restock ?? false,
|
||||
staffNote: params.staffNote || null,
|
||||
refundMethod: params.refundMethod ?? null,
|
||||
restock: params.restock,
|
||||
staffNote: params.staffNote?.trim() || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import type { ShopifyBaseParams } from '@/tools/shopify/types'
|
||||
import type {
|
||||
ShopifyCreateFulfillmentParams,
|
||||
ShopifyFulfillmentResponse,
|
||||
} from '@/tools/shopify/types'
|
||||
import { FULFILLMENT_OUTPUT_PROPERTIES } from '@/tools/shopify/types'
|
||||
import type { ToolConfig, ToolResponse } from '@/tools/types'
|
||||
|
||||
interface ShopifyCreateFulfillmentParams extends ShopifyBaseParams {
|
||||
fulfillmentOrderId: string
|
||||
trackingNumber?: string
|
||||
trackingCompany?: string
|
||||
trackingUrl?: string
|
||||
notifyCustomer?: boolean
|
||||
}
|
||||
|
||||
interface ShopifyCreateFulfillmentResponse extends ToolResponse {
|
||||
output: {
|
||||
fulfillment?: {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
trackingInfo: Array<{
|
||||
company: string | null
|
||||
number: string | null
|
||||
url: string | null
|
||||
}>
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<string, number> = {}
|
||||
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<string, number> = {}
|
||||
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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,11 @@ const IMAGE_PROPERTIES = {
|
||||
altText: { type: 'string', description: 'Alternative text for accessibility', optional: true },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
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<string, OutputProperty>
|
||||
|
||||
/** 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<string, OutputProperty>
|
||||
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<TrelloAddCommentParams, TrelloAddC
|
||||
if (!params.cardId) {
|
||||
throw new Error('Card ID is required')
|
||||
}
|
||||
const apiKey = env.TRELLO_API_KEY || ''
|
||||
const token = params.accessToken
|
||||
return `https://api.trello.com/1/cards/${params.cardId}/actions/comments?key=${apiKey}&token=${token}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<TrelloCreateCardParams, TrelloCreateCardResponse> = {
|
||||
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<TrelloCreateCardParams, TrelloCrea
|
||||
visibility: 'hidden',
|
||||
description: 'Trello OAuth access token',
|
||||
},
|
||||
boardId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Trello board ID (24-character hex string)',
|
||||
},
|
||||
listId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
@@ -56,19 +55,37 @@ export const trelloCreateCardTool: ToolConfig<TrelloCreateCardParams, TrelloCrea
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Due date (ISO 8601 format)',
|
||||
},
|
||||
labels: {
|
||||
type: 'string',
|
||||
dueComplete: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated list of label IDs (24-character hex strings)',
|
||||
description: 'Whether the due date should be marked complete',
|
||||
},
|
||||
labelIds: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Label IDs to attach to the card',
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'A Trello label ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
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<TrelloCreateCardParams, TrelloCrea
|
||||
throw new Error('List ID is required')
|
||||
}
|
||||
|
||||
const body: Record<string, any> = {
|
||||
idList: params.listId,
|
||||
const body: Record<string, unknown> = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<TrelloGetActionsParams, TrelloGetA
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of actions to return (default: 50, max: 1000)',
|
||||
description: 'Maximum number of board actions to return',
|
||||
},
|
||||
page: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page number for action results',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,21 +66,32 @@ export const trelloGetActionsTool: ToolConfig<TrelloGetActionsParams, TrelloGetA
|
||||
throw new Error('Provide either boardId or cardId, not both')
|
||||
}
|
||||
|
||||
const id = params.boardId || params.cardId
|
||||
const type = params.boardId ? 'boards' : 'cards'
|
||||
const apiKey = env.TRELLO_API_KEY || ''
|
||||
const token = params.accessToken
|
||||
const apiKey = env.TRELLO_API_KEY
|
||||
|
||||
let url = `https://api.trello.com/1/${type}/${id}/actions?key=${apiKey}&token=${token}&fields=id,type,date,memberCreator,data`
|
||||
|
||||
if (params.filter) {
|
||||
url += `&filter=${params.filter}`
|
||||
if (!apiKey) {
|
||||
throw new Error('TRELLO_API_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
const limit = params.limit || 50
|
||||
url += `&limit=${limit}`
|
||||
const path = params.boardId
|
||||
? `/boards/${params.boardId.trim()}/actions`
|
||||
: `/cards/${params.cardId?.trim()}/actions`
|
||||
const url = new URL(`${TRELLO_API_BASE_URL}${path}`)
|
||||
url.searchParams.set('key', apiKey)
|
||||
url.searchParams.set('token', params.accessToken)
|
||||
|
||||
return url
|
||||
if (params.filter) {
|
||||
url.searchParams.set('filter', params.filter)
|
||||
}
|
||||
|
||||
if (params.boardId && params.limit !== undefined) {
|
||||
url.searchParams.set('limit', String(params.limit))
|
||||
}
|
||||
|
||||
if (params.page !== undefined) {
|
||||
url.searchParams.set('page', String(params.page))
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
@@ -78,33 +100,148 @@ export const trelloGetActionsTool: ToolConfig<TrelloGetActionsParams, TrelloGetA
|
||||
},
|
||||
|
||||
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 get Trello actions')
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
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,
|
||||
},
|
||||
if (!Array.isArray(data)) {
|
||||
const error = 'Trello returned an invalid action collection'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
actions: [],
|
||||
count: 0,
|
||||
error,
|
||||
},
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
},
|
||||
|
||||
@@ -13,3 +13,5 @@ export {
|
||||
trelloGetActionsTool,
|
||||
trelloAddCommentTool,
|
||||
}
|
||||
|
||||
export * from '@/tools/trello/types'
|
||||
|
||||
@@ -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<TrelloListCardsParams, TrelloListCardsResponse> = {
|
||||
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<TrelloListCardsParams, TrelloListCa
|
||||
},
|
||||
boardId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Trello board ID (24-character hex string)',
|
||||
description: 'Trello board ID to list open cards from. Provide either boardId or listId',
|
||||
},
|
||||
listId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Trello list ID to filter cards (24-character hex string)',
|
||||
description: 'Trello list ID to list cards from. Provide either boardId or listId',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
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<TrelloListCardsParams, TrelloListCa
|
||||
},
|
||||
|
||||
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 list Trello cards')
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
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,
|
||||
},
|
||||
if (!Array.isArray(data)) {
|
||||
const error = 'Trello returned an invalid card collection'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
cards: [],
|
||||
count: 0,
|
||||
error,
|
||||
},
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
},
|
||||
|
||||
@@ -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<TrelloListListsParams, TrelloListListsResponse> = {
|
||||
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<TrelloListListsParams, TrelloListLi
|
||||
if (!params.boardId) {
|
||||
throw new Error('Board ID is required')
|
||||
}
|
||||
const apiKey = env.TRELLO_API_KEY || ''
|
||||
const token = params.accessToken
|
||||
const apiKey = env.TRELLO_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('TRELLO_API_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
if (!params.accessToken) {
|
||||
throw new Error('Trello access token is missing')
|
||||
}
|
||||
|
||||
return `https://api.trello.com/1/boards/${params.boardId}/lists?key=${apiKey}&token=${token}`
|
||||
const url = new URL(`${TRELLO_API_BASE_URL}/boards/${params.boardId.trim()}/lists`)
|
||||
url.searchParams.set('key', apiKey)
|
||||
url.searchParams.set('token', params.accessToken)
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
@@ -53,33 +61,75 @@ export const trelloListListsTool: ToolConfig<TrelloListListsParams, TrelloListLi
|
||||
},
|
||||
|
||||
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 list Trello lists')
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
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,
|
||||
},
|
||||
if (!Array.isArray(data)) {
|
||||
const error = 'Trello returned an invalid list collection'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
lists: [],
|
||||
count: 0,
|
||||
error,
|
||||
},
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type {
|
||||
TrelloAction,
|
||||
TrelloActionBoardTarget,
|
||||
TrelloActionCardTarget,
|
||||
TrelloActionListTarget,
|
||||
TrelloCard,
|
||||
TrelloComment,
|
||||
TrelloLabel,
|
||||
TrelloList,
|
||||
TrelloMember,
|
||||
} from '@/tools/trello/types'
|
||||
|
||||
type TrelloRecord = Record<string, unknown>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,37 +36,44 @@ 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 TrelloActionCardTarget {
|
||||
id: string
|
||||
name: string
|
||||
shortLink: string | null
|
||||
idShort: number | null
|
||||
due: string | null
|
||||
}
|
||||
|
||||
export interface TrelloActionBoardTarget {
|
||||
id: 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
|
||||
}
|
||||
data: Record<string, any>
|
||||
idMemberCreator: string
|
||||
text: string | null
|
||||
memberCreator: TrelloMember | null
|
||||
card: TrelloActionCardTarget | null
|
||||
board: TrelloActionBoardTarget | null
|
||||
list: TrelloActionListTarget | null
|
||||
}
|
||||
|
||||
export interface TrelloComment {
|
||||
id: string
|
||||
text: string
|
||||
date: string
|
||||
memberCreator: {
|
||||
id: string
|
||||
fullName: string
|
||||
username: string
|
||||
}
|
||||
}
|
||||
export interface TrelloComment extends TrelloAction {}
|
||||
|
||||
export interface TrelloListListsParams {
|
||||
accessToken: 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 {
|
||||
|
||||
@@ -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<TrelloUpdateCardParams, TrelloUpda
|
||||
if (!params.cardId) {
|
||||
throw new Error('Card ID is required')
|
||||
}
|
||||
const apiKey = env.TRELLO_API_KEY || ''
|
||||
const token = params.accessToken
|
||||
return `https://api.trello.com/1/cards/${params.cardId}?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/${params.cardId.trim()}`)
|
||||
url.searchParams.set('key', apiKey)
|
||||
url.searchParams.set('token', params.accessToken)
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
method: 'PUT',
|
||||
headers: () => ({
|
||||
@@ -79,12 +92,12 @@ export const trelloUpdateCardTool: ToolConfig<TrelloUpdateCardParams, TrelloUpda
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, any> = {}
|
||||
const body: Record<string, unknown> = {}
|
||||
|
||||
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<TrelloUpdateCardParams, TrelloUpda
|
||||
},
|
||||
|
||||
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 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export const sendMessageTool: ToolConfig<WhatsAppSendMessageParams, WhatsAppResponse> = {
|
||||
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<WhatsAppSendMessageParams, WhatsAppResp
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Message content to send (plain text or template content)',
|
||||
description: 'Plain text message content to send',
|
||||
},
|
||||
phoneNumberId: {
|
||||
type: 'string',
|
||||
@@ -32,6 +36,13 @@ export const sendMessageTool: ToolConfig<WhatsAppSendMessageParams, WhatsAppResp
|
||||
visibility: 'user-only',
|
||||
description: 'WhatsApp Business API Access Token (from Meta Developer Portal)',
|
||||
},
|
||||
previewUrl: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Whether WhatsApp should try to render a link preview for the first URL in the message',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -39,7 +50,7 @@ export const sendMessageTool: ToolConfig<WhatsAppSendMessageParams, WhatsAppResp
|
||||
if (!params.phoneNumberId) {
|
||||
throw new Error('WhatsApp Phone Number ID is required')
|
||||
}
|
||||
return `https://graph.facebook.com/v22.0/${params.phoneNumberId}/messages`
|
||||
return `https://graph.facebook.com/v25.0/${params.phoneNumberId.trim()}/messages`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
@@ -47,12 +58,11 @@ export const sendMessageTool: ToolConfig<WhatsAppSendMessageParams, WhatsAppResp
|
||||
throw new Error('WhatsApp Access Token is required')
|
||||
}
|
||||
return {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
Authorization: `Bearer ${params.accessToken.trim()}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
body: (params) => {
|
||||
// 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<WhatsAppSendMessageParams, WhatsAppResp
|
||||
throw new Error('Message content is required but was not provided')
|
||||
}
|
||||
|
||||
// Format the phone number (remove + if present)
|
||||
const formattedPhoneNumber = params.phoneNumber.startsWith('+')
|
||||
? params.phoneNumber.substring(1)
|
||||
: params.phoneNumber
|
||||
const text: Record<string, boolean | string> = {
|
||||
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: ToolConfig<WhatsAppSendMessageParams, WhatsAppResp
|
||||
outputs: {
|
||||
success: { type: 'boolean', description: 'WhatsApp message send success status' },
|
||||
messageId: { type: 'string', description: 'Unique WhatsApp message identifier' },
|
||||
phoneNumber: { type: 'string', description: 'Recipient phone number' },
|
||||
status: { type: 'string', description: 'Message delivery status' },
|
||||
timestamp: { type: 'string', description: 'Message send timestamp' },
|
||||
messageStatus: {
|
||||
type: 'string',
|
||||
description: 'Initial delivery state returned by the API',
|
||||
optional: true,
|
||||
},
|
||||
messagingProduct: {
|
||||
type: 'string',
|
||||
description: 'Messaging product returned by the API',
|
||||
optional: true,
|
||||
},
|
||||
inputPhoneNumber: {
|
||||
type: 'string',
|
||||
description: 'Recipient phone number echoed back by WhatsApp',
|
||||
optional: true,
|
||||
},
|
||||
whatsappUserId: {
|
||||
type: 'string',
|
||||
description: 'WhatsApp user ID resolved for the recipient',
|
||||
optional: true,
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
description: 'Recipient contact records returned by WhatsApp',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string', description: 'Input phone number sent to the API' },
|
||||
wa_id: {
|
||||
type: 'string',
|
||||
description: 'WhatsApp user ID associated with the recipient',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,12 +5,23 @@ export interface WhatsAppSendMessageParams {
|
||||
message: string
|
||||
phoneNumberId: string
|
||||
accessToken: string
|
||||
previewUrl?: boolean
|
||||
}
|
||||
|
||||
export interface WhatsAppMessageContact {
|
||||
input: string
|
||||
wa_id?: string | null
|
||||
}
|
||||
|
||||
export interface WhatsAppResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
messageId?: string
|
||||
messageStatus?: string
|
||||
messagingProduct?: string
|
||||
inputPhoneNumber?: string | null
|
||||
whatsappUserId?: string | null
|
||||
contacts?: WhatsAppMessageContact[]
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export const whatsappWebhookTrigger: TriggerConfig = {
|
||||
id: 'whatsapp_webhook',
|
||||
name: 'WhatsApp Webhook',
|
||||
provider: 'whatsapp',
|
||||
description: 'Trigger workflow from WhatsApp messages and events via Business Platform webhooks',
|
||||
description: 'Trigger workflow from WhatsApp incoming messages and message status webhooks',
|
||||
version: '1.0.0',
|
||||
icon: WhatsAppIcon,
|
||||
|
||||
@@ -31,20 +31,32 @@ export const whatsappWebhookTrigger: TriggerConfig = {
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'appSecret',
|
||||
title: 'App Secret',
|
||||
type: 'short-input',
|
||||
placeholder: 'Paste your Meta app secret',
|
||||
description:
|
||||
'Required for WhatsApp POST signature verification. Sim uses it to validate the X-Hub-Signature-256 header on every webhook delivery.',
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: [
|
||||
'Go to your <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Meta for Developers Apps</a> page and navigate to the "Build with us" --> "App Events" section.',
|
||||
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
|
||||
'Select your App, then navigate to WhatsApp > Configuration.',
|
||||
'Find the Webhooks section and click "Edit".',
|
||||
'Go to your <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Meta App Dashboard</a> 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 <strong>Use cases > Customize > Configuration</strong> instead of <strong>WhatsApp > Configuration</strong>.',
|
||||
'If you do not already have an app, create one first and add the WhatsApp product before configuring webhooks.',
|
||||
'Click <strong>"Save Configuration"</strong> 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 <strong>WhatsApp > Configuration</strong>, find the <strong>Webhooks</strong> section and click <strong>Edit</strong>.',
|
||||
'Paste the <strong>Webhook URL</strong> above into the "Callback URL" field.',
|
||||
'Paste the <strong>Verification Token</strong> into the "Verify token" field.',
|
||||
"Copy your app's <strong>App Secret</strong> from <strong>App Settings > Basic</strong> and paste it into the <strong>App Secret</strong> field above so Sim can validate POST signatures.",
|
||||
'Click "Verify and save".',
|
||||
'Click "Manage" next to Webhook fields and subscribe to `messages`.',
|
||||
'Click <strong>Manage</strong> next to webhook fields and subscribe to <code>messages</code>. 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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user