v0.6.39: billing fixes, tools audit, landing fix

This commit is contained in:
Waleed
2026-04-12 22:32:14 -07:00
committed by GitHub
69 changed files with 5785 additions and 1324 deletions

View File

@@ -14,6 +14,20 @@ When the user asks you to create a block:
2. Configure all subBlocks with proper types, conditions, and dependencies
3. Wire up tools correctly
## Hard Rule: No Guessed Tool Outputs
Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs.
- Do NOT invent block outputs for undocumented tool responses
- Do NOT describe unknown JSON shapes as if they were confirmed
- Do NOT wire fields into the block just because they seem likely to exist
If the tool outputs are not known, do one of these instead:
1. Ask the user for sample tool responses
2. Ask the user for test credentials so the tool responses can be verified
3. Limit the block to operations whose outputs are documented
4. Leave uncertain outputs out and explicitly tell the user what remains unknown
## Block Configuration Structure
```typescript
@@ -575,6 +589,8 @@ Use `type: 'json'` with a descriptive string when:
- It represents a list/array of items
- The shape varies by operation
If the output shape is unknown because the underlying tool response is undocumented, you MUST tell the user and stop. Unknown is not the same as variable. Never guess block outputs.
## V2 Block Pattern
When creating V2 blocks (alongside legacy V1):
@@ -829,3 +845,4 @@ After creating the block, you MUST validate it against every tool it references:
- Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse())
3. **Verify block outputs** cover the key fields returned by all tools
4. **Verify conditions** — each subBlock should only show for the operations that actually use it
5. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs

View File

@@ -15,6 +15,21 @@ When the user asks you to create a connector:
3. Create the connector directory and config
4. Register it in the connector registry
## Hard Rule: No Guessed Response Or Document Schemas
If the service docs do not clearly show the document list response, document fetch response, pagination shape, or metadata fields, you MUST tell the user instead of guessing.
- Do NOT invent document fields
- Do NOT guess pagination cursors or next-page fields
- Do NOT infer metadata/tag mappings from unrelated endpoints
- Do NOT fabricate `ExternalDocument` content structure from partial docs
If the source schema is unknown, do one of these instead:
1. Ask the user for sample API responses
2. Ask the user for test credentials so you can verify live payloads
3. Implement only the documented parts of the connector
4. Leave the connector incomplete and explicitly say which fields remain unknown
## Directory Structure
Create files in `apps/sim/connectors/{service}/`:
@@ -92,6 +107,8 @@ export const {service}Connector: ConnectorConfig = {
}
```
Only map fields in `listDocuments`, `getDocument`, `validateConfig`, and `mapTags` when the source payload shape is documented or live-verified. If not, tell the user and stop rather than guessing.
### API key connector example
```typescript

View File

@@ -29,6 +29,21 @@ Before writing any code:
- Required vs optional parameters
- Response structures
### Hard Rule: No Guessed Response Schemas
If the official docs do not clearly show the response JSON shape for an endpoint, you MUST stop and tell the user exactly which outputs are unknown.
- Do NOT guess response field names
- Do NOT infer nested JSON paths from related endpoints
- Do NOT invent output properties just because they seem likely
- Do NOT implement `transformResponse` against unverified payload shapes
If response schemas are missing or incomplete, do one of the following before proceeding:
1. Ask the user for sample responses
2. Ask the user for test credentials so you can verify the live payload
3. Reduce the scope to only endpoints whose response shapes are documented
4. Leave the tool unimplemented and explicitly report why
## Step 2: Create Tools
### Directory Structure
@@ -103,6 +118,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
- Set `optional: true` for outputs that may not exist
- Never output raw JSON dumps - extract meaningful fields
- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic
- If you do not know the response JSON shape from docs or verified examples, you MUST tell the user and stop. Never guess outputs or response mappings.
## Step 3: Create Block
@@ -450,6 +466,8 @@ If creating V2 versions (API-aligned outputs):
- [ ] Verified block subBlocks cover all required tool params with correct conditions
- [ ] Verified block outputs match what the tools actually return
- [ ] Verified `tools.config.params` correctly maps and coerces all param types
- [ ] Verified every tool output and `transformResponse` path against documented or live-verified JSON responses
- [ ] If any response schema remained unknown, explicitly told the user instead of guessing
## Example Command

View File

@@ -14,6 +14,21 @@ When the user asks you to create tools for a service:
2. Create the tools directory structure
3. Generate properly typed tool configurations
## Hard Rule: No Guessed Response Schemas
If the docs do not clearly show the response JSON for a tool, you MUST tell the user exactly which outputs are unknown and stop short of guessing.
- Do NOT invent response field names
- Do NOT infer nested paths from nearby endpoints
- Do NOT guess array item shapes
- Do NOT write `transformResponse` against unverified payloads
If the response shape is unknown, do one of these instead:
1. Ask the user for sample responses
2. Ask the user for test credentials so you can verify live responses
3. Implement only the endpoints whose outputs are documented
4. Leave the tool unimplemented and explicitly say why
## Directory Structure
Create files in `apps/sim/tools/{service}/`:
@@ -187,6 +202,8 @@ items: {
Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown.
If the response shape is unknown because the docs do not provide it, you MUST tell the user and stop. Unknown is not the same as dynamic. Never guess outputs.
## Critical Rules for transformResponse
### Handle Nullable Fields
@@ -441,7 +458,9 @@ After creating all tools, you MUST validate every tool before finishing:
- All output fields match what the API actually returns
- No fields are missing from outputs that the API provides
- No extra fields are defined in outputs that the API doesn't return
- Every output field and JSON path is backed by docs or live-verified sample responses
3. **Verify consistency** across tools:
- Shared types in `types.ts` match all tools that use them
- Tool IDs in the barrel export match the tool file definitions
- Error handling is consistent (error checks, meaningful messages)
4. **If any response schema is still unknown**, explicitly tell the user instead of guessing

View File

@@ -14,6 +14,21 @@ You are an expert at creating webhook triggers for Sim. You understand the trigg
3. Create a provider handler if custom auth, formatting, or subscriptions are needed
4. Register triggers and connect them to the block
## Hard Rule: No Guessed Webhook Payload Schemas
If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or `formatInput` mappings.
- Do NOT invent payload field names
- Do NOT guess nested event object paths
- Do NOT infer output fields from the UI or marketing docs
- Do NOT write `formatInput` against unverified webhook bodies
If the payload shape is unknown, do one of these instead:
1. Ask the user for sample webhook payloads
2. Ask the user for a test webhook source so you can inspect a real event
3. Implement only the event registration/setup portions whose payloads are documented
4. Leave the trigger unimplemented and explicitly say which payload fields are unknown
## Directory Structure
```

View File

@@ -52,6 +52,20 @@ Fetch the official API docs for the service. This is the **source of truth** for
Use Context7 (resolve-library-id → query-docs) or WebFetch to retrieve documentation. If both fail, note which claims are based on training knowledge vs verified docs.
### Hard Rule: No Guessed Source Schemas
If the service docs do not clearly show document list responses, document fetch responses, metadata fields, or pagination shapes, you MUST tell the user instead of guessing.
- Do NOT infer document fields from unrelated endpoints
- Do NOT guess pagination cursors or response wrappers
- Do NOT assume metadata keys that are not documented
- Do NOT treat probable shapes as validated
If a schema is unknown, validation must explicitly recommend:
1. sample API responses,
2. live test credentials, or
3. trimming the connector to only documented fields.
## Step 3: Validate API Endpoints
For **every** API call in the connector (`listDocuments`, `getDocument`, `validateConfig`, and any helper functions), verify against the API docs:
@@ -93,6 +107,7 @@ For **every** API call in the connector (`listDocuments`, `getDocument`, `valida
- [ ] Field names extracted match what the API actually returns
- [ ] Nullable fields are handled with `?? null` or `|| undefined`
- [ ] Error responses are checked before accessing data fields
- [ ] Every extracted field and pagination value is backed by official docs or live-verified sample payloads
## Step 4: Validate OAuth Scopes (if OAuth connector)
@@ -304,6 +319,7 @@ After fixing, confirm:
1. `bun run lint` passes
2. TypeScript compiles clean
3. Re-read all modified files to verify fixes are correct
4. Any remaining unknown source schemas were explicitly reported to the user instead of guessed
## Checklist Summary

View File

@@ -41,6 +41,20 @@ Fetch the official API docs for the service. This is the **source of truth** for
- Pagination patterns (which param name, which response field)
- Rate limits and error formats
### Hard Rule: No Guessed Response Schemas
If the official docs do not clearly show the response JSON shape for an endpoint, you MUST tell the user instead of guessing.
- Do NOT assume field names from nearby endpoints
- Do NOT infer nested JSON paths without evidence
- Do NOT treat "likely" fields as confirmed outputs
- Do NOT accept implementation guesses as valid just because they are defensive
If a response schema is unknown, the validation must explicitly call that out and require:
1. sample responses from the user,
2. live test credentials for verification, or
3. trimming the tool/block down to only documented fields.
## Step 3: Validate Tools
For **every** tool file, check:
@@ -81,6 +95,7 @@ For **every** tool file, check:
- [ ] All optional arrays use `?? []`
- [ ] Error cases are handled: checks for missing/empty data and returns meaningful error
- [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields
- [ ] Every extracted field is backed by official docs or live-verified sample payloads
### Outputs
- [ ] All output fields match what the API actually returns
@@ -267,6 +282,7 @@ After fixing, confirm:
1. `bun run lint` passes with no fixes needed
2. TypeScript compiles clean (no type errors)
3. Re-read all modified files to verify fixes are correct
4. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
## Checklist Summary

View File

@@ -44,6 +44,20 @@ Fetch the service's official webhook documentation. This is the **source of trut
- Webhook subscription API (create/delete endpoints, if applicable)
- Retry behavior and delivery guarantees
### Hard Rule: No Guessed Webhook Payload Schemas
If the official docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing.
- Do NOT invent payload field names
- Do NOT infer nested payload paths without evidence
- Do NOT treat likely event shapes as verified
- Do NOT accept `formatInput` mappings that are not backed by docs or live payloads
If a payload schema is unknown, validation must explicitly recommend:
1. sample webhook payloads,
2. a live test webhook source, or
3. trimming the trigger to only documented outputs.
## Step 3: Validate Trigger Definitions
### utils.ts
@@ -93,6 +107,7 @@ Fetch the service's official webhook documentation. This is the **source of trut
- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
- [ ] Returns `{ input: { ... } }` — not a bare object
- [ ] Every mapped payload field is backed by official docs or live-verified webhook payloads
### Idempotency
- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
@@ -195,6 +210,7 @@ After fixing, confirm:
1. `bun run type-check` passes
2. Re-read all modified files to verify fixes are correct
3. Provider handler tests pass (if they exist): `bun test {service}`
4. Any remaining unknown webhook payload schemas were explicitly reported to the user instead of guessed
## Checklist Summary

View 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
@@ -3554,7 +3565,7 @@ export function FireworksIcon(props: SVGProps<SVGSVGElement>) {
>
<path
d='M314.333 110.167L255.98 251.729l-58.416-141.562h-37.459l64 154.75c5.23 12.854 17.771 21.312 31.646 21.312s26.417-8.437 31.646-21.27l64.396-154.792h-37.459zm24.917 215.666L446 216.583l-14.562-34.77-116.584 119.562c-9.708 9.958-12.541 24.833-7.146 37.646 5.292 12.73 17.792 21.083 31.584 21.083l.042.063L506 359.75l-14.562-34.77-152.146.853h-.042zM66 216.5l14.563-34.77 116.583 119.562a34.592 34.592 0 017.146 37.646C199 351.667 186.5 360.02 172.708 360.02l-166.666-.375-.042.042 14.563-34.771 152.145.875L66 216.5z'
fill='currentColor'
fill='#5019c5'
/>
</svg>
)

View File

@@ -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,

View File

@@ -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 |

View File

@@ -27,6 +27,7 @@
"cloudformation",
"cloudwatch",
"confluence",
"crowdstrike",
"cursor",
"dagster",
"databricks",

View File

@@ -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

View File

@@ -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 teams 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 |

View File

@@ -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 |

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@ function ModelLabel({ model }: ModelLabelProps) {
const Icon = PROVIDER_ICON_MAP[model.providerId]
return (
<div className='flex w-[140px] shrink-0 items-center justify-end gap-1.5 sm:w-[180px]'>
<div className='flex w-[90px] shrink-0 items-center justify-end gap-1.5 sm:w-[140px] lg:w-[180px]'>
{Icon && <Icon className='h-3.5 w-3.5 shrink-0' />}
<span className='truncate font-medium text-[13px] text-[var(--landing-text)] leading-none tracking-[-0.01em]'>
{model.displayName}
@@ -116,7 +116,7 @@ function StackedCostChart({ models }: ChartProps) {
<ModelLabel model={model} />
<div className='relative flex h-7 min-w-0 flex-1 items-center'>
<div
className='flex h-full overflow-hidden rounded-r-[3px]'
className='hidden h-full overflow-hidden rounded-r-[3px] sm:flex'
style={{ width: `${Math.max(totalPct, 3)}%` }}
>
<div
@@ -136,7 +136,7 @@ function StackedCostChart({ models }: ChartProps) {
}}
/>
</div>
<span className='ml-2.5 shrink-0 font-mono text-[var(--landing-text-muted)] text-xs'>
<span className='shrink-0 font-mono text-[11px] text-[var(--landing-text-muted)] sm:ml-2.5 sm:text-xs'>
{formatPrice(input)} input / {formatPrice(output)} output
</span>
</div>
@@ -196,7 +196,7 @@ function ContextWindowChart({ models }: ChartProps) {
opacity: 0.8,
}}
/>
<span className='ml-2.5 shrink-0 font-mono text-[var(--landing-text-muted)] text-xs'>
<span className='ml-2.5 shrink-0 font-mono text-[11px] text-[var(--landing-text-muted)] sm:text-xs'>
{formatTokenCount(value)}
</span>
</div>

View File

@@ -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 {

View File

@@ -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())

View File

@@ -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');

View File

@@ -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,
},

View File

@@ -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,
})
})
})

View File

@@ -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 })
}
}

View File

@@ -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) {

View File

@@ -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' },
},
}

View File

@@ -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

View File

@@ -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',
},
},
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()
})
})

View File

@@ -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.'

View 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

View File

@@ -0,0 +1,239 @@
/**
* @vitest-environment node
*/
import type Stripe from 'stripe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockBlockOrgMembers, mockDbSelect, mockLogger, mockUnblockOrgMembers, selectResponses } =
vi.hoisted(() => {
const selectResponses: Array<{ limitResult?: unknown; whereResult?: unknown }> = []
const mockDbSelect = vi.fn(() => {
const nextResponse = selectResponses.shift()
if (!nextResponse) {
throw new Error('No queued db.select response')
}
const builder = {
from: vi.fn(() => builder),
where: vi.fn(() => builder),
limit: vi.fn(async () => nextResponse.limitResult ?? nextResponse.whereResult ?? []),
then: (resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) =>
Promise.resolve(nextResponse.whereResult ?? nextResponse.limitResult ?? []).then(
resolve,
reject
),
}
return builder
})
return {
mockBlockOrgMembers: vi.fn(),
mockDbSelect,
mockLogger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
mockUnblockOrgMembers: vi.fn(),
selectResponses,
}
})
vi.mock('@sim/db', () => ({
db: {
select: mockDbSelect,
},
}))
vi.mock('@sim/db/schema', () => ({
member: {
organizationId: 'member.organizationId',
role: 'member.role',
userId: 'member.userId',
},
organization: {},
subscription: {
referenceId: 'subscription.referenceId',
stripeSubscriptionId: 'subscription.stripeSubscriptionId',
},
user: {
email: 'user.email',
id: 'user.id',
name: 'user.name',
},
userStats: {
billingBlocked: 'userStats.billingBlocked',
billingBlockedReason: 'userStats.billingBlockedReason',
userId: 'userStats.userId',
},
}))
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => mockLogger),
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn(() => 'and'),
eq: vi.fn(() => 'eq'),
inArray: vi.fn(() => 'inArray'),
isNull: vi.fn(() => 'isNull'),
ne: vi.fn(() => 'ne'),
or: vi.fn(() => 'or'),
}))
vi.mock('@/components/emails', () => ({
PaymentFailedEmail: vi.fn(),
getEmailSubject: vi.fn(),
renderCreditPurchaseEmail: vi.fn(),
}))
vi.mock('@/lib/billing/core/billing', () => ({
calculateSubscriptionOverage: vi.fn(),
}))
vi.mock('@/lib/billing/credits/balance', () => ({
addCredits: vi.fn(),
getCreditBalance: vi.fn(),
removeCredits: vi.fn(),
}))
vi.mock('@/lib/billing/credits/purchase', () => ({
setUsageLimitForCredits: vi.fn(),
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
blockOrgMembers: mockBlockOrgMembers,
unblockOrgMembers: mockUnblockOrgMembers,
}))
vi.mock('@/lib/billing/plan-helpers', () => ({
isEnterprise: vi.fn(() => false),
isOrgPlan: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
isTeam: vi.fn((plan: string | null | undefined) => Boolean(plan?.startsWith('team'))),
}))
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: vi.fn(),
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://sim.test'),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn(),
}))
vi.mock('@/lib/messaging/email/utils', () => ({
getPersonalEmailFrom: vi.fn(() => ({
from: 'billing@sim.test',
replyTo: 'support@sim.test',
})),
}))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: vi.fn(() => ({ isValid: true })),
}))
vi.mock('@react-email/render', () => ({
render: vi.fn(),
}))
import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded } from './invoices'
function queueSelectResponse(response: { limitResult?: unknown; whereResult?: unknown }) {
selectResponses.push(response)
}
function createInvoiceEvent(
type: 'invoice.payment_failed' | 'invoice.payment_succeeded',
invoice: Partial<Stripe.Invoice>
): Stripe.Event {
return {
data: {
object: invoice as Stripe.Invoice,
},
id: `evt_${type}`,
type,
} as Stripe.Event
}
describe('invoice billing recovery', () => {
beforeEach(() => {
vi.clearAllMocks()
selectResponses.length = 0
mockBlockOrgMembers.mockResolvedValue(2)
mockUnblockOrgMembers.mockResolvedValue(2)
})
it('blocks org members when a metadata-backed invoice payment fails', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
await handleInvoicePaymentFailed(
createInvoiceEvent('invoice.payment_failed', {
amount_due: 3582,
attempt_count: 2,
customer: 'cus_123',
customer_email: 'owner@sim.test',
hosted_invoice_url: 'https://stripe.test/invoices/in_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockBlockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockUnblockOrgMembers).not.toHaveBeenCalled()
})
it('unblocks org members when the matching metadata-backed invoice payment succeeds', async () => {
queueSelectResponse({
limitResult: [
{
id: 'sub-db-1',
plan: 'team_8000',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
},
],
})
queueSelectResponse({
whereResult: [{ userId: 'owner-1' }, { userId: 'member-1' }],
})
queueSelectResponse({
whereResult: [{ blocked: false }, { blocked: false }],
})
await handleInvoicePaymentSucceeded(
createInvoiceEvent('invoice.payment_succeeded', {
amount_paid: 3582,
billing_reason: 'manual',
customer: 'cus_123',
id: 'in_123',
metadata: {
billingPeriod: '2026-04',
subscriptionId: 'sub_stripe_1',
type: 'overage_threshold_billing_org',
},
})
)
expect(mockUnblockOrgMembers).toHaveBeenCalledWith('org-1', 'payment_failed')
expect(mockBlockOrgMembers).not.toHaveBeenCalled()
})
})

View File

@@ -24,7 +24,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
const logger = createLogger('StripeInvoiceWebhooks')
const OVERAGE_INVOICE_TYPES = new Set<string>([
const METADATA_SUBSCRIPTION_INVOICE_TYPES = new Set<string>([
'overage_billing',
'overage_threshold_billing',
'overage_threshold_billing_org',
@@ -35,6 +35,116 @@ function parseDecimal(value: string | number | null | undefined): number {
return Number.parseFloat(value.toString())
}
type InvoiceSubscriptionResolutionSource =
| 'parent.subscription_details.subscription'
| 'metadata.subscriptionId'
| 'none'
interface InvoiceSubscriptionContext {
invoiceType: string | null
resolutionSource: InvoiceSubscriptionResolutionSource
stripeSubscriptionId: string | null
}
type BillingSubscription = typeof subscriptionTable.$inferSelect
interface ResolvedInvoiceSubscription extends InvoiceSubscriptionContext {
sub: BillingSubscription
stripeSubscriptionId: string
}
function resolveInvoiceSubscriptionContext(invoice: Stripe.Invoice): InvoiceSubscriptionContext {
const invoiceType = invoice.metadata?.type ?? null
const canResolveFromMetadata = !!(
invoiceType && METADATA_SUBSCRIPTION_INVOICE_TYPES.has(invoiceType)
)
const metadataSubscriptionId =
canResolveFromMetadata &&
typeof invoice.metadata?.subscriptionId === 'string' &&
invoice.metadata.subscriptionId.length > 0
? invoice.metadata.subscriptionId
: null
const parentSubscription = invoice.parent?.subscription_details?.subscription
const parentSubscriptionId =
typeof parentSubscription === 'string' ? parentSubscription : (parentSubscription?.id ?? null)
if (
parentSubscriptionId &&
metadataSubscriptionId &&
parentSubscriptionId !== metadataSubscriptionId
) {
logger.warn('Invoice has conflicting subscription identifiers', {
invoiceId: invoice.id,
invoiceType,
metadataSubscriptionId,
parentSubscriptionId,
})
}
if (parentSubscriptionId) {
return {
invoiceType,
resolutionSource: 'parent.subscription_details.subscription',
stripeSubscriptionId: parentSubscriptionId,
}
}
if (metadataSubscriptionId) {
return {
invoiceType,
resolutionSource: 'metadata.subscriptionId',
stripeSubscriptionId: metadataSubscriptionId,
}
}
return {
invoiceType,
resolutionSource: 'none',
stripeSubscriptionId: null,
}
}
async function resolveInvoiceSubscription(
invoice: Stripe.Invoice,
handlerName: string
): Promise<ResolvedInvoiceSubscription | null> {
const subscriptionContext = resolveInvoiceSubscriptionContext(invoice)
if (!subscriptionContext.stripeSubscriptionId) {
logger.info('No subscription found on invoice; skipping handler', {
handlerName,
invoiceId: invoice.id,
invoiceType: subscriptionContext.invoiceType,
resolutionSource: subscriptionContext.resolutionSource,
})
return null
}
const records = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.stripeSubscriptionId, subscriptionContext.stripeSubscriptionId))
.limit(1)
if (records.length === 0) {
logger.warn('Subscription not found in database for invoice', {
handlerName,
invoiceId: invoice.id,
invoiceType: subscriptionContext.invoiceType,
resolutionSource: subscriptionContext.resolutionSource,
stripeSubscriptionId: subscriptionContext.stripeSubscriptionId,
})
return null
}
return {
...subscriptionContext,
stripeSubscriptionId: subscriptionContext.stripeSubscriptionId,
sub: records[0],
}
}
/**
* Create a billing portal URL for a Stripe customer
*/
@@ -462,21 +572,12 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
return
}
// Handle subscription invoices
const subscription = invoice.parent?.subscription_details?.subscription
const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
if (!stripeSubscriptionId) {
const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_succeeded')
if (!resolvedInvoice) {
return
}
const records = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
.limit(1)
if (records.length === 0) return
const sub = records[0]
const { sub } = resolvedInvoice
// Only reset usage here if the tenant was previously blocked; otherwise invoice.created already reset it
let wasBlocked = false
@@ -550,27 +651,13 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
try {
const invoice = event.data.object as Stripe.Invoice
const invoiceType = invoice.metadata?.type
const isOverageInvoice = !!(invoiceType && OVERAGE_INVOICE_TYPES.has(invoiceType))
let stripeSubscriptionId: string | undefined
if (isOverageInvoice) {
// Overage invoices store subscription ID in metadata
stripeSubscriptionId = invoice.metadata?.subscriptionId as string | undefined
} else {
// Regular subscription invoices have it in parent.subscription_details
const subscription = invoice.parent?.subscription_details?.subscription
stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
}
if (!stripeSubscriptionId) {
logger.info('No subscription found on invoice; skipping payment failed handler', {
invoiceId: invoice.id,
isOverageInvoice,
})
const resolvedInvoice = await resolveInvoiceSubscription(invoice, 'invoice.payment_failed')
if (!resolvedInvoice) {
return
}
const { invoiceType, resolutionSource, stripeSubscriptionId, sub } = resolvedInvoice
// Extract and validate customer ID
const customerId = invoice.customer
if (!customerId || typeof customerId !== 'string') {
@@ -593,75 +680,57 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
attemptCount,
customerEmail: invoice.customer_email,
hostedInvoiceUrl: invoice.hosted_invoice_url,
isOverageInvoice,
invoiceType: isOverageInvoice ? 'overage' : 'subscription',
invoiceType: invoiceType ?? 'subscription',
resolutionSource,
})
// Block users after first payment failure
if (attemptCount >= 1) {
const records = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
.limit(1)
logger.error('Payment failure - blocking users', {
customerId,
attemptCount,
invoiceId: invoice.id,
invoiceType: invoiceType ?? 'subscription',
resolutionSource,
stripeSubscriptionId,
})
if (records.length > 0) {
const sub = records[0]
logger.error('Payment failure - blocking users', {
invoiceId: invoice.id,
customerId,
attemptCount,
isOverageInvoice,
stripeSubscriptionId,
if (isOrgPlan(sub.plan)) {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
logger.info('Blocked team/enterprise members due to payment failure', {
invoiceType: invoiceType ?? 'subscription',
memberCount,
organizationId: sub.referenceId,
})
if (isOrgPlan(sub.plan)) {
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
logger.info('Blocked team/enterprise members due to payment failure', {
organizationId: sub.referenceId,
memberCount,
isOverageInvoice,
})
} else {
// Don't overwrite dispute blocks (dispute > payment_failed priority)
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
} else {
await db
.update(userStats)
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
.where(
and(
eq(userStats.userId, sub.referenceId),
or(
ne(userStats.billingBlockedReason, 'dispute'),
isNull(userStats.billingBlockedReason)
)
)
logger.info('Blocked user due to payment failure', {
userId: sub.referenceId,
isOverageInvoice,
})
}
)
logger.info('Blocked user due to payment failure', {
invoiceType: invoiceType ?? 'subscription',
userId: sub.referenceId,
})
}
// Send payment failure notification emails
// Only send on FIRST failure (attempt_count === 1), not on Stripe's automatic retries
// This prevents spamming users with duplicate emails every 3-5-7 days
if (attemptCount === 1) {
await sendPaymentFailureEmails(sub, invoice, customerId)
logger.info('Payment failure email sent on first attempt', {
invoiceId: invoice.id,
customerId,
})
} else {
logger.info('Skipping payment failure email on retry attempt', {
invoiceId: invoice.id,
attemptCount,
customerId,
})
}
if (attemptCount === 1) {
await sendPaymentFailureEmails(sub, invoice, customerId)
logger.info('Payment failure email sent on first attempt', {
customerId,
invoiceId: invoice.id,
})
} else {
logger.warn('Subscription not found in database for failed payment', {
stripeSubscriptionId,
logger.info('Skipping payment failure email on retry attempt', {
attemptCount,
customerId,
invoiceId: invoice.id,
})
}

View File

@@ -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',

View File

@@ -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' },
},
},
])
})
})

View File

@@ -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' }
},
}

View File

@@ -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',
},
},
}

View File

@@ -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 },
},
},
},
}

View File

@@ -0,0 +1,4 @@
export { crowdstrikeGetSensorAggregatesTool } from './get_sensor_aggregates'
export { crowdstrikeGetSensorDetailsTool } from './get_sensor_details'
export { crowdstrikeQuerySensorsTool } from './query_sensors'
export * from './types'

View File

@@ -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 },
},
},
},
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
},
],

View File

@@ -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,
},
}
},

View File

@@ -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,

View File

@@ -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,
},
}
},

View File

@@ -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,
},
}

View File

@@ -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,

View File

@@ -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'

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}>
}
}
}

View File

@@ -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,
},
}
},

View File

@@ -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' },
},
},
},
},
},
}

View File

@@ -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,
},
},
},
},
}

View File

@@ -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' },
},

View File

@@ -13,3 +13,5 @@ export {
trelloGetActionsTool,
trelloAddCommentTool,
}
export * from '@/tools/trello/types'

View File

@@ -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' },
},

View File

@@ -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' },
},

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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,
},
},
},
},
}

View File

@@ -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'

View File

@@ -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,
},
},
},
},
},
}

View File

@@ -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
}
}

View File

@@ -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 &gt; Customize &gt; Configuration</strong> instead of <strong>WhatsApp &gt; 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 &gt; 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 &gt; 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',
},
},