diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md new file mode 100644 index 0000000000..40f15e772f --- /dev/null +++ b/.claude/commands/add-block.md @@ -0,0 +1,591 @@ +--- +description: Create a block configuration for a Sim Studio integration with proper subBlocks, conditions, and tool wiring +argument-hint: +--- + +# Add Block Skill + +You are an expert at creating block configurations for Sim Studio. You understand the serializer, subBlock types, conditions, dependsOn, modes, and all UI patterns. + +## Your Task + +When the user asks you to create a block: +1. Create the block file in `apps/sim/blocks/blocks/{service}.ts` +2. Configure all subBlocks with proper types, conditions, and dependencies +3. Wire up tools correctly + +## Block Configuration Structure + +```typescript +import { {ServiceName}Icon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' + +export const {ServiceName}Block: BlockConfig = { + type: '{service}', // snake_case identifier + name: '{Service Name}', // Human readable + description: 'Brief description', // One sentence + longDescription: 'Detailed description for docs', + docsLink: 'https://docs.sim.ai/tools/{service}', + category: 'tools', // 'tools' | 'blocks' | 'triggers' + bgColor: '#HEXCOLOR', // Brand color + icon: {ServiceName}Icon, + + // Auth mode + authMode: AuthMode.OAuth, // or AuthMode.ApiKey + + subBlocks: [ + // Define all UI fields here + ], + + tools: { + access: ['tool_id_1', 'tool_id_2'], // Array of tool IDs this block can use + config: { + tool: (params) => `{service}_${params.operation}`, // Tool selector function + params: (params) => ({ + // Transform subBlock values to tool params + }), + }, + }, + + inputs: { + // Optional: define expected inputs from other blocks + }, + + outputs: { + // Define outputs available to downstream blocks + }, +} +``` + +## SubBlock Types Reference + +**Critical:** Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions. + +### Text Inputs +```typescript +// Single-line input +{ id: 'field', title: 'Label', type: 'short-input', placeholder: '...' } + +// Multi-line input +{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 } + +// Password input +{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true } +``` + +### Selection Inputs +```typescript +// Dropdown (static options) +{ + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create', id: 'create' }, + { label: 'Update', id: 'update' }, + ], + value: () => 'create', // Default value function +} + +// Combobox (searchable dropdown) +{ + id: 'field', + title: 'Label', + type: 'combobox', + options: [...], + searchable: true, +} +``` + +### Code/JSON Inputs +```typescript +{ + id: 'code', + title: 'Code', + type: 'code', + language: 'javascript', // 'javascript' | 'json' | 'python' + placeholder: '// Enter code...', +} +``` + +### OAuth/Credentials +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', // Must match OAuth provider + placeholder: 'Select account', + required: true, +} +``` + +### Selectors (with dynamic options) +```typescript +// Channel selector (Slack, Discord, etc.) +{ + id: 'channel', + title: 'Channel', + type: 'channel-selector', + serviceId: '{service}', + placeholder: 'Select channel', + dependsOn: ['credential'], +} + +// Project selector (Jira, etc.) +{ + id: 'project', + title: 'Project', + type: 'project-selector', + serviceId: '{service}', + dependsOn: ['credential'], +} + +// File selector (Google Drive, etc.) +{ + id: 'file', + title: 'File', + type: 'file-selector', + serviceId: '{service}', + mimeType: 'application/pdf', + dependsOn: ['credential'], +} + +// User selector +{ + id: 'user', + title: 'User', + type: 'user-selector', + serviceId: '{service}', + dependsOn: ['credential'], +} +``` + +### Other Types +```typescript +// Switch/toggle +{ id: 'enabled', type: 'switch' } + +// Slider +{ id: 'temperature', title: 'Temperature', type: 'slider', min: 0, max: 2, step: 0.1 } + +// Table (key-value pairs) +{ id: 'headers', title: 'Headers', type: 'table', columns: ['Key', 'Value'] } + +// File upload +{ + id: 'files', + title: 'Attachments', + type: 'file-upload', + multiple: true, + acceptedTypes: 'image/*,application/pdf', +} +``` + +## Condition Syntax + +Controls when a field is shown based on other field values. + +### Simple Condition +```typescript +condition: { field: 'operation', value: 'create' } +// Shows when operation === 'create' +``` + +### Multiple Values (OR) +```typescript +condition: { field: 'operation', value: ['create', 'update'] } +// Shows when operation is 'create' OR 'update' +``` + +### Negation +```typescript +condition: { field: 'operation', value: 'delete', not: true } +// Shows when operation !== 'delete' +``` + +### Compound (AND) +```typescript +condition: { + field: 'operation', + value: 'send', + and: { + field: 'type', + value: 'dm', + not: true, + } +} +// Shows when operation === 'send' AND type !== 'dm' +``` + +### Complex Example +```typescript +condition: { + field: 'operation', + value: ['list', 'search'], + not: true, + and: { + field: 'authMethod', + value: 'oauth', + } +} +// Shows when operation NOT in ['list', 'search'] AND authMethod === 'oauth' +``` + +## DependsOn Pattern + +Controls when a field is enabled and when its options are refetched. + +### Simple Array (all must be set) +```typescript +dependsOn: ['credential'] +// Enabled only when credential has a value +// Options refetch when credential changes + +dependsOn: ['credential', 'projectId'] +// Enabled only when BOTH have values +``` + +### Complex (all + any) +```typescript +dependsOn: { + all: ['authMethod'], // All must be set + any: ['credential', 'apiKey'] // At least one must be set +} +// Enabled when authMethod is set AND (credential OR apiKey is set) +``` + +## Required Pattern + +Can be boolean or condition-based. + +### Simple Boolean +```typescript +required: true +required: false +``` + +### Conditional Required +```typescript +required: { field: 'operation', value: 'create' } +// Required only when operation === 'create' + +required: { field: 'operation', value: ['create', 'update'] } +// Required when operation is 'create' OR 'update' +``` + +## Mode Pattern (Basic vs Advanced) + +Controls which UI view shows the field. + +### Mode Options +- `'basic'` - Only in basic view (default UI) +- `'advanced'` - Only in advanced view +- `'both'` - Both views (default if not specified) +- `'trigger'` - Only in trigger configuration + +### canonicalParamId Pattern + +Maps multiple UI fields to a single serialized parameter: + +```typescript +// Basic mode: Visual selector +{ + id: 'channel', + title: 'Channel', + type: 'channel-selector', + mode: 'basic', + canonicalParamId: 'channel', // Both map to 'channel' param + dependsOn: ['credential'], +} + +// Advanced mode: Manual input +{ + id: 'channelId', + title: 'Channel ID', + type: 'short-input', + mode: 'advanced', + canonicalParamId: 'channel', // Both map to 'channel' param + placeholder: 'Enter channel ID manually', +} +``` + +**How it works:** +- In basic mode: `channel` selector value → `params.channel` +- In advanced mode: `channelId` input value → `params.channel` +- The serializer consolidates based on current mode + +**Critical constraints:** +- `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts) +- `canonicalParamId` must be unique per block (only one basic/advanced pair per canonicalParamId) +- ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter +- Do NOT use it for any other purpose + +## WandConfig Pattern + +Enables AI-assisted field generation. + +```typescript +{ + id: 'query', + title: 'Query', + type: 'code', + language: 'json', + wandConfig: { + enabled: true, + prompt: 'Generate a query based on the user request. Return ONLY the JSON.', + placeholder: 'Describe what you want to query...', + generationType: 'json-object', // Optional: affects AI behavior + maintainHistory: true, // Optional: keeps conversation context + }, +} +``` + +### Generation Types +- `'javascript-function-body'` - JS code generation +- `'json-object'` - Raw JSON (adds "no markdown" instruction) +- `'json-schema'` - JSON Schema definitions +- `'sql-query'` - SQL statements +- `'timestamp'` - Adds current date/time context + +## Tools Configuration + +### Simple Tool Selector +```typescript +tools: { + access: ['service_create', 'service_read', 'service_update'], + config: { + tool: (params) => `service_${params.operation}`, + }, +} +``` + +### With Parameter Transformation +```typescript +tools: { + access: ['service_action'], + config: { + tool: (params) => 'service_action', + params: (params) => ({ + id: params.resourceId, + data: typeof params.data === 'string' ? JSON.parse(params.data) : params.data, + }), + }, +} +``` + +### V2 Versioned Tool Selector +```typescript +import { createVersionedToolSelector } from '@/blocks/utils' + +tools: { + access: [ + 'service_create_v2', + 'service_read_v2', + 'service_update_v2', + ], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: (params) => `service_${params.operation}`, + suffix: '_v2', + fallbackToolId: 'service_create_v2', + }), + }, +} +``` + +## Outputs Definition + +**IMPORTANT:** Block outputs have a simpler schema than tool outputs. Block outputs do NOT support: +- `optional: true` - This is only for tool outputs +- `items` property - This is only for tool outputs with array types + +Block outputs only support: +- `type` - The data type ('string', 'number', 'boolean', 'json', 'array') +- `description` - Human readable description +- Nested object structure (for complex types) + +```typescript +outputs: { + // Simple outputs + id: { type: 'string', description: 'Resource ID' }, + success: { type: 'boolean', description: 'Whether operation succeeded' }, + + // Use type: 'json' for complex objects or arrays (NOT type: 'array' with items) + items: { type: 'json', description: 'List of items' }, + metadata: { type: 'json', description: 'Response metadata' }, + + // Nested outputs (for structured data) + user: { + id: { type: 'string', description: 'User ID' }, + name: { type: 'string', description: 'User name' }, + email: { type: 'string', description: 'User email' }, + }, +} +``` + +## V2 Block Pattern + +When creating V2 blocks (alongside legacy V1): + +```typescript +// V1 Block - mark as legacy +export const ServiceBlock: BlockConfig = { + type: 'service', + name: 'Service (Legacy)', + hideFromToolbar: true, // Hide from toolbar + // ... rest of config +} + +// V2 Block - visible, uses V2 tools +export const ServiceV2Block: BlockConfig = { + type: 'service_v2', + name: 'Service', // Clean name + hideFromToolbar: false, // Visible + subBlocks: ServiceBlock.subBlocks, // Reuse UI + tools: { + access: ServiceBlock.tools?.access?.map(id => `${id}_v2`) || [], + config: { + tool: createVersionedToolSelector({ + baseToolSelector: (params) => (ServiceBlock.tools?.config as any)?.tool(params), + suffix: '_v2', + fallbackToolId: 'service_default_v2', + }), + params: ServiceBlock.tools?.config?.params, + }, + }, + outputs: { + // Flat, API-aligned outputs (not wrapped in content/metadata) + }, +} +``` + +## Registering Blocks + +After creating the block, remind the user to: +1. Import in `apps/sim/blocks/registry.ts` +2. Add to the `registry` object (alphabetically): + +```typescript +import { ServiceBlock } from '@/blocks/blocks/service' + +export const registry: Record = { + // ... existing blocks ... + service: ServiceBlock, +} +``` + +## Complete Example + +```typescript +import { ServiceIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' + +export const ServiceBlock: BlockConfig = { + type: 'service', + name: 'Service', + description: 'Integrate with Service API', + longDescription: 'Full description for documentation...', + docsLink: 'https://docs.sim.ai/tools/service', + category: 'tools', + bgColor: '#FF6B6B', + icon: ServiceIcon, + authMode: AuthMode.OAuth, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Create', id: 'create' }, + { label: 'Read', id: 'read' }, + { label: 'Update', id: 'update' }, + { label: 'Delete', id: 'delete' }, + ], + value: () => 'create', + }, + { + id: 'credential', + title: 'Service Account', + type: 'oauth-input', + serviceId: 'service', + placeholder: 'Select account', + required: true, + }, + { + id: 'resourceId', + title: 'Resource ID', + type: 'short-input', + placeholder: 'Enter resource ID', + condition: { field: 'operation', value: ['read', 'update', 'delete'] }, + required: { field: 'operation', value: ['read', 'update', 'delete'] }, + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + placeholder: 'Resource name', + condition: { field: 'operation', value: ['create', 'update'] }, + required: { field: 'operation', value: 'create' }, + }, + ], + + tools: { + access: ['service_create', 'service_read', 'service_update', 'service_delete'], + config: { + tool: (params) => `service_${params.operation}`, + }, + }, + + outputs: { + id: { type: 'string', description: 'Resource ID' }, + name: { type: 'string', description: 'Resource name' }, + createdAt: { type: 'string', description: 'Creation timestamp' }, + }, +} +``` + +## Connecting Blocks with Triggers + +If the service supports webhooks, connect the block to its triggers. + +```typescript +import { getTrigger } from '@/triggers' + +export const ServiceBlock: BlockConfig = { + // ... basic config ... + + triggers: { + enabled: true, + available: ['service_event_a', 'service_event_b', 'service_webhook'], + }, + + subBlocks: [ + // Tool subBlocks first... + { id: 'operation', /* ... */ }, + + // Then spread trigger subBlocks + ...getTrigger('service_event_a').subBlocks, + ...getTrigger('service_event_b').subBlocks, + ...getTrigger('service_webhook').subBlocks, + ], +} +``` + +See the `/add-trigger` skill for creating triggers. + +## Checklist Before Finishing + +- [ ] All subBlocks have `id`, `title` (except switch), and `type` +- [ ] Conditions use correct syntax (field, value, not, and) +- [ ] DependsOn set for fields that need other values +- [ ] Required fields marked correctly (boolean or condition) +- [ ] OAuth inputs have correct `serviceId` +- [ ] Tools.access lists all tool IDs +- [ ] Tools.config.tool returns correct tool ID +- [ ] Outputs match tool outputs +- [ ] Block registered in registry.ts +- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md new file mode 100644 index 0000000000..017bebcffb --- /dev/null +++ b/.claude/commands/add-integration.md @@ -0,0 +1,450 @@ +--- +description: Add a complete integration to Sim Studio (tools, block, icon, registration) +argument-hint: [api-docs-url] +--- + +# Add Integration Skill + +You are an expert at adding complete integrations to Sim Studio. This skill orchestrates the full process of adding a new service integration. + +## Overview + +Adding an integration involves these steps in order: +1. **Research** - Read the service's API documentation +2. **Create Tools** - Build tool configurations for each API operation +3. **Create Block** - Build the block UI configuration +4. **Add Icon** - Add the service's brand icon +5. **Create Triggers** (optional) - If the service supports webhooks +6. **Register** - Register tools, block, and triggers in their registries +7. **Generate Docs** - Run the docs generation script + +## Step 1: Research the API + +Before writing any code: +1. Use Context7 to find official documentation: `mcp__plugin_context7_context7__resolve-library-id` +2. Or use WebFetch to read API docs directly +3. Identify: + - Authentication method (OAuth, API Key, both) + - Available operations (CRUD, search, etc.) + - Required vs optional parameters + - Response structures + +## Step 2: Create Tools + +### Directory Structure +``` +apps/sim/tools/{service}/ +├── index.ts # Barrel exports +├── types.ts # TypeScript interfaces +├── {action1}.ts # Tool for action 1 +├── {action2}.ts # Tool for action 2 +└── ... +``` + +### Key Patterns + +**types.ts:** +```typescript +import type { ToolResponse } from '@/tools/types' + +export interface {Service}{Action}Params { + accessToken: string // For OAuth services + // OR + apiKey: string // For API key services + + requiredParam: string + optionalParam?: string +} + +export interface {Service}Response extends ToolResponse { + output: { + // Define output structure + } +} +``` + +**Tool file pattern:** +```typescript +export const {service}{Action}Tool: ToolConfig = { + id: '{service}_{action}', + name: '{Service} {Action}', + description: '...', + version: '1.0.0', + + oauth: { required: true, provider: '{service}' }, // If OAuth + + params: { + accessToken: { type: 'string', required: true, visibility: 'hidden', description: '...' }, + // ... other params + }, + + request: { url, method, headers, body }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + field: data.field ?? null, // Always handle nullables + }, + } + }, + + outputs: { /* ... */ }, +} +``` + +### Critical Rules +- `visibility: 'hidden'` for OAuth tokens +- `visibility: 'user-only'` for API keys and user credentials +- `visibility: 'user-or-llm'` for operation parameters +- Always use `?? null` for nullable API response fields +- Always use `?? []` for optional array fields +- Set `optional: true` for outputs that may not exist +- Never output raw JSON dumps - extract meaningful fields + +## Step 3: Create Block + +### File Location +`apps/sim/blocks/blocks/{service}.ts` + +### Block Structure +```typescript +import { {Service}Icon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' + +export const {Service}Block: BlockConfig = { + type: '{service}', + name: '{Service}', + description: '...', + longDescription: '...', + docsLink: 'https://docs.sim.ai/tools/{service}', + category: 'tools', + bgColor: '#HEXCOLOR', + icon: {Service}Icon, + authMode: AuthMode.OAuth, // or AuthMode.ApiKey + + subBlocks: [ + // Operation dropdown + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Operation 1', id: 'action1' }, + { label: 'Operation 2', id: 'action2' }, + ], + value: () => 'action1', + }, + // Credential field + { + id: 'credential', + title: '{Service} Account', + type: 'oauth-input', + serviceId: '{service}', + required: true, + }, + // Conditional fields per operation + // ... + ], + + tools: { + access: ['{service}_action1', '{service}_action2'], + config: { + tool: (params) => `{service}_${params.operation}`, + }, + }, + + outputs: { /* ... */ }, +} +``` + +### Key SubBlock Patterns + +**Condition-based visibility:** +```typescript +{ + id: 'resourceId', + title: 'Resource ID', + type: 'short-input', + condition: { field: 'operation', value: ['read', 'update', 'delete'] }, + required: { field: 'operation', value: ['read', 'update', 'delete'] }, +} +``` + +**DependsOn for cascading selectors:** +```typescript +{ + id: 'project', + type: 'project-selector', + dependsOn: ['credential'], +}, +{ + id: 'issue', + type: 'file-selector', + dependsOn: ['credential', 'project'], +} +``` + +**Basic/Advanced mode for dual UX:** +```typescript +// Basic: Visual selector +{ + id: 'channel', + type: 'channel-selector', + mode: 'basic', + canonicalParamId: 'channel', + dependsOn: ['credential'], +}, +// Advanced: Manual input +{ + id: 'channelId', + type: 'short-input', + mode: 'advanced', + canonicalParamId: 'channel', +} +``` + +**Critical:** +- `canonicalParamId` must NOT match any other subblock's `id`, must be unique per block, and should only be used to link basic/advanced alternatives for the same parameter. +- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent. +- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions. + +## Step 4: Add Icon + +### File Location +`apps/sim/components/icons.tsx` + +### Pattern +```typescript +export function {Service}Icon(props: SVGProps) { + return ( + + {/* SVG paths from brand assets */} + + ) +} +``` + +### Finding Icons +1. Check the service's brand/press kit page +2. Download SVG logo +3. Convert to React component +4. Ensure it accepts and spreads props + +## Step 5: Create Triggers (Optional) + +If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. + +### Directory Structure +``` +apps/sim/triggers/{service}/ +├── index.ts # Barrel exports +├── utils.ts # Trigger options, setup instructions, extra fields +├── {event_a}.ts # Primary trigger (includes dropdown) +├── {event_b}.ts # Secondary triggers (no dropdown) +└── webhook.ts # Generic webhook (optional) +``` + +### Key Pattern + +```typescript +import { buildTriggerSubBlocks } from '@/triggers' +import { {service}TriggerOptions, {service}SetupInstructions, build{Service}ExtraFields } from './utils' + +// Primary trigger - includeDropdown: true +export const {service}EventATrigger: TriggerConfig = { + id: '{service}_event_a', + subBlocks: buildTriggerSubBlocks({ + triggerId: '{service}_event_a', + triggerOptions: {service}TriggerOptions, + includeDropdown: true, // Only for primary trigger! + setupInstructions: {service}SetupInstructions('Event A'), + extraFields: build{Service}ExtraFields('{service}_event_a'), + }), + // ... +} + +// Secondary triggers - no dropdown +export const {service}EventBTrigger: TriggerConfig = { + id: '{service}_event_b', + subBlocks: buildTriggerSubBlocks({ + triggerId: '{service}_event_b', + triggerOptions: {service}TriggerOptions, + // No includeDropdown! + setupInstructions: {service}SetupInstructions('Event B'), + extraFields: build{Service}ExtraFields('{service}_event_b'), + }), + // ... +} +``` + +### Connect to Block +```typescript +import { getTrigger } from '@/triggers' + +export const {Service}Block: BlockConfig = { + triggers: { + enabled: true, + available: ['{service}_event_a', '{service}_event_b'], + }, + subBlocks: [ + // Tool fields... + ...getTrigger('{service}_event_a').subBlocks, + ...getTrigger('{service}_event_b').subBlocks, + ], +} +``` + +See `/add-trigger` skill for complete documentation. + +## Step 6: Register Everything + +### Tools Registry (`apps/sim/tools/registry.ts`) + +```typescript +// Add import (alphabetically) +import { + {service}Action1Tool, + {service}Action2Tool, +} from '@/tools/{service}' + +// Add to tools object (alphabetically) +export const tools: Record = { + // ... existing tools ... + {service}_action1: {service}Action1Tool, + {service}_action2: {service}Action2Tool, +} +``` + +### Block Registry (`apps/sim/blocks/registry.ts`) + +```typescript +// Add import (alphabetically) +import { {Service}Block } from '@/blocks/blocks/{service}' + +// Add to registry (alphabetically) +export const registry: Record = { + // ... existing blocks ... + {service}: {Service}Block, +} +``` + +### Trigger Registry (`apps/sim/triggers/registry.ts`) - If triggers exist + +```typescript +// Add import (alphabetically) +import { + {service}EventATrigger, + {service}EventBTrigger, + {service}WebhookTrigger, +} from '@/triggers/{service}' + +// Add to TRIGGER_REGISTRY (alphabetically) +export const TRIGGER_REGISTRY: TriggerRegistry = { + // ... existing triggers ... + {service}_event_a: {service}EventATrigger, + {service}_event_b: {service}EventBTrigger, + {service}_webhook: {service}WebhookTrigger, +} +``` + +## Step 7: Generate Docs + +Run the documentation generator: +```bash +bun run scripts/generate-docs.ts +``` + +This creates `apps/docs/content/docs/en/tools/{service}.mdx` + +## V2 Integration Pattern + +If creating V2 versions (API-aligned outputs): + +1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs +2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector` +3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true` +4. **Registry** - Register both versions + +```typescript +// In registry +{service}: {Service}Block, // V1 (legacy, hidden) +{service}_v2: {Service}V2Block, // V2 (visible) +``` + +## Complete Checklist + +### Tools +- [ ] Created `tools/{service}/` directory +- [ ] Created `types.ts` with all interfaces +- [ ] Created tool file for each operation +- [ ] All params have correct visibility +- [ ] All nullable fields use `?? null` +- [ ] All optional outputs have `optional: true` +- [ ] Created `index.ts` barrel export +- [ ] Registered all tools in `tools/registry.ts` + +### Block +- [ ] Created `blocks/blocks/{service}.ts` +- [ ] Defined operation dropdown with all operations +- [ ] Added credential field (oauth-input or short-input) +- [ ] Added conditional fields per operation +- [ ] Set up dependsOn for cascading selectors +- [ ] Configured tools.access with all tool IDs +- [ ] Configured tools.config.tool selector +- [ ] Defined outputs matching tool outputs +- [ ] Registered block in `blocks/registry.ts` +- [ ] If triggers: set `triggers.enabled` and `triggers.available` +- [ ] If triggers: spread trigger subBlocks with `getTrigger()` + +### Icon +- [ ] Added icon to `components/icons.tsx` +- [ ] Icon spreads props correctly + +### Triggers (if service supports webhooks) +- [ ] Created `triggers/{service}/` directory +- [ ] Created `utils.ts` with options, instructions, and extra fields helpers +- [ ] Primary trigger uses `includeDropdown: true` +- [ ] Secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper +- [ ] Created `index.ts` barrel export +- [ ] Registered all triggers in `triggers/registry.ts` + +### Docs +- [ ] Ran `bun run scripts/generate-docs.ts` +- [ ] Verified docs file created + +## Example Command + +When the user asks to add an integration: + +``` +User: Add a Stripe integration + +You: I'll add the Stripe integration. Let me: + +1. First, research the Stripe API using Context7 +2. Create the tools for key operations (payments, subscriptions, etc.) +3. Create the block with operation dropdown +4. Add the Stripe icon +5. Register everything +6. Generate docs + +[Proceed with implementation...] +``` + +## Common Gotchas + +1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration +2. **Tool IDs are snake_case** - `stripe_create_payment`, not `stripeCreatePayment` +3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` +4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted +5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true +6. **DependsOn clears options** - When a dependency changes, selector options are refetched diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md new file mode 100644 index 0000000000..d4dfee8706 --- /dev/null +++ b/.claude/commands/add-tools.md @@ -0,0 +1,284 @@ +--- +description: Create tool configurations for a Sim Studio integration by reading API docs +argument-hint: [api-docs-url] +--- + +# Add Tools Skill + +You are an expert at creating tool configurations for Sim Studio integrations. Your job is to read API documentation and create properly structured tool files. + +## Your Task + +When the user asks you to create tools for a service: +1. Use Context7 or WebFetch to read the service's API documentation +2. Create the tools directory structure +3. Generate properly typed tool configurations + +## Directory Structure + +Create files in `apps/sim/tools/{service}/`: +``` +tools/{service}/ +├── index.ts # Barrel export +├── types.ts # Parameter & response types +└── {action}.ts # Individual tool files (one per operation) +``` + +## Tool Configuration Structure + +Every tool MUST follow this exact structure: + +```typescript +import type { {ServiceName}{Action}Params } from '@/tools/{service}/types' +import type { ToolConfig } from '@/tools/types' + +interface {ServiceName}{Action}Response { + success: boolean + output: { + // Define output structure here + } +} + +export const {serviceName}{Action}Tool: ToolConfig< + {ServiceName}{Action}Params, + {ServiceName}{Action}Response +> = { + id: '{service}_{action}', // snake_case, matches tool name + name: '{Service} {Action}', // Human readable + description: 'Brief description', // One sentence + version: '1.0.0', + + // OAuth config (if service uses OAuth) + oauth: { + required: true, + provider: '{service}', // Must match OAuth provider ID + }, + + params: { + // Hidden params (system-injected) + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token', + }, + // User-only params (credentials, IDs user must provide) + someId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The ID of the resource', + }, + // User-or-LLM params (can be provided by user OR computed by LLM) + query: { + type: 'string', + required: false, // Use false for optional + visibility: 'user-or-llm', + description: 'Search query', + }, + }, + + request: { + url: (params) => `https://api.service.com/v1/resource/${params.id}`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => ({ + // Request body - only for POST/PUT/PATCH + // Trim ID fields to prevent copy-paste whitespace errors: + // userId: params.userId?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: true, + output: { + // Map API response to output + // Use ?? null for nullable fields + // Use ?? [] for optional arrays + }, + } + }, + + outputs: { + // Define each output field + }, +} +``` + +## Critical Rules for Parameters + +### Visibility Options +- `'hidden'` - System-injected (OAuth tokens, internal params). User never sees. +- `'user-only'` - User must provide (credentials, account-specific IDs) +- `'user-or-llm'` - User provides OR LLM can compute (search queries, content, filters) + +### Parameter Types +- `'string'` - Text values +- `'number'` - Numeric values +- `'boolean'` - True/false +- `'json'` - Complex objects (NOT 'object', use 'json') +- `'file'` - Single file +- `'file[]'` - Multiple files + +### Required vs Optional +- Always explicitly set `required: true` or `required: false` +- Optional params should have `required: false` + +## Critical Rules for Outputs + +### Output Types +- `'string'`, `'number'`, `'boolean'` - Primitives +- `'json'` - Complex objects (use this, NOT 'object') +- `'array'` - Arrays with `items` property +- `'object'` - Objects with `properties` property + +### Optional Outputs +Add `optional: true` for fields that may not exist in the response: +```typescript +closedAt: { + type: 'string', + description: 'When the issue was closed', + optional: true, +}, +``` + +### Nested Properties +For complex outputs, define nested structure: +```typescript +metadata: { + type: 'json', + description: 'Response metadata', + properties: { + id: { type: 'string', description: 'Unique ID' }, + status: { type: 'string', description: 'Current status' }, + count: { type: 'number', description: 'Total count' }, + }, +}, + +items: { + type: 'array', + description: 'List of items', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Item ID' }, + name: { type: 'string', description: 'Item name' }, + }, + }, +}, +``` + +## Critical Rules for transformResponse + +### Handle Nullable Fields +ALWAYS use `?? null` for fields that may be undefined: +```typescript +transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: true, + output: { + id: data.id, + title: data.title, + body: data.body ?? null, // May be undefined + assignee: data.assignee ?? null, // May be undefined + labels: data.labels ?? [], // Default to empty array + closedAt: data.closed_at ?? null, // May be undefined + }, + } +} +``` + +### Never Output Raw JSON Dumps +DON'T do this: +```typescript +output: { + data: data, // BAD - raw JSON dump +} +``` + +DO this instead - extract meaningful fields: +```typescript +output: { + id: data.id, + name: data.name, + status: data.status, + metadata: { + createdAt: data.created_at, + updatedAt: data.updated_at, + }, +} +``` + +## Types File Pattern + +Create `types.ts` with interfaces for all params and responses: + +```typescript +import type { ToolResponse } from '@/tools/types' + +// Parameter interfaces +export interface {Service}{Action}Params { + accessToken: string + requiredField: string + optionalField?: string +} + +// Response interfaces (extend ToolResponse) +export interface {Service}{Action}Response extends ToolResponse { + output: { + field1: string + field2: number + optionalField?: string | null + } +} +``` + +## Index.ts Barrel Export Pattern + +```typescript +// Export all tools +export { serviceTool1 } from './{action1}' +export { serviceTool2 } from './{action2}' + +// Export types +export * from './types' +``` + +## Registering Tools + +After creating tools, remind the user to: +1. Import tools in `apps/sim/tools/registry.ts` +2. Add to the `tools` object with snake_case keys: +```typescript +import { serviceActionTool } from '@/tools/{service}' + +export const tools = { + // ... existing tools ... + {service}_{action}: serviceActionTool, +} +``` + +## V2 Tool Pattern + +If creating V2 tools (API-aligned outputs), use `_v2` suffix: +- Tool ID: `{service}_{action}_v2` +- Variable name: `{action}V2Tool` +- Version: `'2.0.0'` +- Outputs: Flat, API-aligned (no content/metadata wrapper) + +## Checklist Before Finishing + +- [ ] All params have explicit `required: true` or `required: false` +- [ ] All params have appropriate `visibility` +- [ ] All nullable response fields use `?? null` +- [ ] All optional outputs have `optional: true` +- [ ] No raw JSON dumps in outputs +- [ ] Types file has all interfaces +- [ ] Index.ts exports all tools +- [ ] Tool IDs use snake_case diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md new file mode 100644 index 0000000000..461563ab0c --- /dev/null +++ b/.claude/commands/add-trigger.md @@ -0,0 +1,656 @@ +--- +description: Create webhook triggers for a Sim Studio integration using the generic trigger builder +argument-hint: +--- + +# Add Trigger Skill + +You are an expert at creating webhook triggers for Sim Studio. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. + +## Your Task + +When the user asks you to create triggers for a service: +1. Research what webhook events the service supports +2. Create the trigger files using the generic builder +3. Register triggers and connect them to the block + +## Directory Structure + +``` +apps/sim/triggers/{service}/ +├── index.ts # Barrel exports +├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields) +├── {event_a}.ts # Primary trigger (includes dropdown) +├── {event_b}.ts # Secondary trigger (no dropdown) +├── {event_c}.ts # Secondary trigger (no dropdown) +└── webhook.ts # Generic webhook trigger (optional, for "all events") +``` + +## Step 1: Create utils.ts + +This file contains service-specific helpers used by all triggers. + +```typescript +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the trigger type selector. + * These appear in the primary trigger's dropdown. + */ +export const {service}TriggerOptions = [ + { label: 'Event A', id: '{service}_event_a' }, + { label: 'Event B', id: '{service}_event_b' }, + { label: 'Event C', id: '{service}_event_c' }, + { label: 'Generic Webhook (All Events)', id: '{service}_webhook' }, +] + +/** + * Generates HTML setup instructions for the trigger. + * Displayed to users to help them configure webhooks in the external service. + */ +export function {service}SetupInstructions(eventType: string): string { + const instructions = [ + 'Copy the Webhook URL above', + 'Go to {Service} Settings > Webhooks', + 'Click Add Webhook', + 'Paste the webhook URL', + `Select the ${eventType} event type`, + 'Save the webhook configuration', + 'Click "Save" above to activate your trigger', + ] + + return instructions + .map((instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Service-specific extra fields to add to triggers. + * These are inserted between webhookUrl and triggerSave. + */ +export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'projectId', + title: 'Project ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for all projects', + description: 'Optionally filter to a specific project', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Build outputs for this trigger type. + * Outputs define what data is available to downstream blocks. + */ +export function build{Service}Outputs(): Record { + return { + eventType: { type: 'string', description: 'The type of event that triggered this workflow' }, + resourceId: { type: 'string', description: 'ID of the affected resource' }, + timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + // Nested outputs for complex data + resource: { + id: { type: 'string', description: 'Resource ID' }, + name: { type: 'string', description: 'Resource name' }, + status: { type: 'string', description: 'Current status' }, + }, + webhook: { type: 'json', description: 'Full webhook payload' }, + } +} +``` + +## Step 2: Create the Primary Trigger + +The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types. + +```typescript +import { {Service}Icon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + build{Service}ExtraFields, + build{Service}Outputs, + {service}SetupInstructions, + {service}TriggerOptions, +} from '@/triggers/{service}/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * {Service} Event A Trigger + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + */ +export const {service}EventATrigger: TriggerConfig = { + id: '{service}_event_a', + name: '{Service} Event A', + provider: '{service}', + description: 'Trigger workflow when Event A occurs', + version: '1.0.0', + icon: {Service}Icon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: '{service}_event_a', + triggerOptions: {service}TriggerOptions, + includeDropdown: true, // PRIMARY TRIGGER - includes dropdown + setupInstructions: {service}SetupInstructions('Event A'), + extraFields: build{Service}ExtraFields('{service}_event_a'), + }), + + outputs: build{Service}Outputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} +``` + +## Step 3: Create Secondary Triggers + +Secondary triggers do NOT include the dropdown (it's already in the primary trigger). + +```typescript +import { {Service}Icon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + build{Service}ExtraFields, + build{Service}Outputs, + {service}SetupInstructions, + {service}TriggerOptions, +} from '@/triggers/{service}/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * {Service} Event B Trigger + */ +export const {service}EventBTrigger: TriggerConfig = { + id: '{service}_event_b', + name: '{Service} Event B', + provider: '{service}', + description: 'Trigger workflow when Event B occurs', + version: '1.0.0', + icon: {Service}Icon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: '{service}_event_b', + triggerOptions: {service}TriggerOptions, + // NO includeDropdown - secondary trigger + setupInstructions: {service}SetupInstructions('Event B'), + extraFields: build{Service}ExtraFields('{service}_event_b'), + }), + + outputs: build{Service}Outputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} +``` + +## Step 4: Create index.ts Barrel Export + +```typescript +export { {service}EventATrigger } from './event_a' +export { {service}EventBTrigger } from './event_b' +export { {service}EventCTrigger } from './event_c' +export { {service}WebhookTrigger } from './webhook' +``` + +## Step 5: Register Triggers + +### Trigger Registry (`apps/sim/triggers/registry.ts`) + +```typescript +// Add import +import { + {service}EventATrigger, + {service}EventBTrigger, + {service}EventCTrigger, + {service}WebhookTrigger, +} from '@/triggers/{service}' + +// Add to TRIGGER_REGISTRY +export const TRIGGER_REGISTRY: TriggerRegistry = { + // ... existing triggers ... + {service}_event_a: {service}EventATrigger, + {service}_event_b: {service}EventBTrigger, + {service}_event_c: {service}EventCTrigger, + {service}_webhook: {service}WebhookTrigger, +} +``` + +## Step 6: Connect Triggers to Block + +In the block file (`apps/sim/blocks/blocks/{service}.ts`): + +```typescript +import { {Service}Icon } from '@/components/icons' +import { getTrigger } from '@/triggers' +import type { BlockConfig } from '@/blocks/types' + +export const {Service}Block: BlockConfig = { + type: '{service}', + name: '{Service}', + // ... other config ... + + // Enable triggers and list available trigger IDs + triggers: { + enabled: true, + available: [ + '{service}_event_a', + '{service}_event_b', + '{service}_event_c', + '{service}_webhook', + ], + }, + + subBlocks: [ + // Regular tool subBlocks first + { id: 'operation', /* ... */ }, + { id: 'credential', /* ... */ }, + // ... other tool fields ... + + // Then spread ALL trigger subBlocks + ...getTrigger('{service}_event_a').subBlocks, + ...getTrigger('{service}_event_b').subBlocks, + ...getTrigger('{service}_event_c').subBlocks, + ...getTrigger('{service}_webhook').subBlocks, + ], + + // ... tools config ... +} +``` + +## Automatic Webhook Registration (Preferred) + +If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. + +### When to Use Automatic Registration + +Check the service's API documentation for endpoints like: +- `POST /webhooks` or `POST /hooks` - Create webhook +- `DELETE /webhooks/{id}` - Delete webhook + +Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. + +### Implementation Steps + +#### 1. Add API Key to Extra Fields + +Update your `build{Service}ExtraFields` function to include an API key field: + +```typescript +export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your {Service} API key', + description: 'Required to create the webhook in {Service}.', + password: true, + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + // Other optional fields (e.g., campaign filter, project filter) + { + id: 'projectId', + title: 'Project ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for all projects', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} +``` + +#### 2. Update Setup Instructions for Automatic Creation + +Change instructions to indicate automatic webhook creation: + +```typescript +export function {service}SetupInstructions(eventType: string): string { + const instructions = [ + 'Enter your {Service} API Key above.', + 'You can find your API key in {Service} at Settings > API.', + `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`, + 'The webhook will be automatically deleted when you remove this trigger.', + ] + + return instructions + .map((instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} +``` + +#### 3. Add Webhook Creation to API Route + +In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: + +```typescript +// --- {Service} specific logic --- +if (savedWebhook && provider === '{service}') { + logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) + try { + const result = await create{Service}WebhookSubscription( + { + id: savedWebhook.id, + path: savedWebhook.path, + providerConfig: savedWebhook.providerConfig, + }, + requestId + ) + + if (result) { + // Update the webhook record with the external webhook ID + const updatedConfig = { + ...(savedWebhook.providerConfig as Record), + externalId: result.id, + } + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, + updatedAt: new Date(), + }) + .where(eq(webhook.id, savedWebhook.id)) + + savedWebhook.providerConfig = updatedConfig + logger.info(`[${requestId}] Successfully created {Service} webhook`, { + externalHookId: result.id, + webhookId: savedWebhook.id, + }) + } + } catch (err) { + logger.error( + `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, + err + ) + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + return NextResponse.json( + { + error: 'Failed to create webhook in {Service}', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } +} +// --- End {Service} specific logic --- +``` + +Then add the helper function at the end of the file: + +```typescript +async function create{Service}WebhookSubscription( + webhookData: any, + requestId: string +): Promise<{ id: string } | undefined> { + try { + const { path, providerConfig } = webhookData + const { apiKey, triggerId, projectId } = providerConfig || {} + + if (!apiKey) { + throw new Error('{Service} API Key is required.') + } + + // Map trigger IDs to service event types + const eventTypeMap: Record = { + {service}_event_a: 'eventA', + {service}_event_b: 'eventB', + {service}_webhook: undefined, // Generic - no filter + } + + const eventType = eventTypeMap[triggerId] + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const requestBody: Record = { + url: notificationUrl, + } + + if (eventType) { + requestBody.eventType = eventType + } + + if (projectId) { + requestBody.projectId = projectId + } + + const response = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await response.json() + + if (!response.ok) { + const errorMessage = responseBody.message || 'Unknown API error' + let userFriendlyMessage = 'Failed to create webhook in {Service}' + + if (response.status === 401) { + userFriendlyMessage = 'Invalid API Key. Please verify and try again.' + } else if (errorMessage) { + userFriendlyMessage = `{Service} error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + return { id: responseBody.id } + } catch (error: any) { + logger.error(`Exception during {Service} webhook creation`, { error: error.message }) + throw error + } +} +``` + +#### 4. Add Webhook Deletion to Provider Subscriptions + +In `apps/sim/lib/webhooks/provider-subscriptions.ts`: + +1. Add a logger: +```typescript +const {service}Logger = createLogger('{Service}Webhook') +``` + +2. Add the delete function: +```typescript +export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey || !externalId) { + {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) + return + } + + const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok && response.status !== 404) { + {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) + } else { + {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) + } + } catch (error) { + {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) + } +} +``` + +3. Add to `cleanupExternalWebhook`: +```typescript +export async function cleanupExternalWebhook(...): Promise { + // ... existing providers ... + } else if (webhook.provider === '{service}') { + await delete{Service}Webhook(webhook, requestId) + } +} +``` + +### Key Points for Automatic Registration + +- **API Key visibility**: Always use `password: true` for API key fields +- **Error handling**: Roll back the database webhook if external creation fails +- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` +- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) +- **User-friendly errors**: Map HTTP status codes to helpful error messages + +## The buildTriggerSubBlocks Helper + +This is the generic helper from `@/triggers` that creates consistent trigger subBlocks. + +### Function Signature + +```typescript +interface BuildTriggerSubBlocksOptions { + triggerId: string // e.g., 'service_event_a' + triggerOptions: Array<{ label: string; id: string }> // Dropdown options + includeDropdown?: boolean // true only for primary trigger + setupInstructions: string // HTML instructions + extraFields?: SubBlockConfig[] // Service-specific fields + webhookPlaceholder?: string // Custom placeholder text +} + +function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] +``` + +### What It Creates + +The helper creates this structure: +1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector +2. **Webhook URL** - Read-only field with copy button +3. **Extra Fields** - Your service-specific fields (filters, options, etc.) +4. **Save Button** - Activates the trigger +5. **Instructions** - Setup guide for users + +All fields automatically have: +- `mode: 'trigger'` - Only shown in trigger mode +- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected + +## Trigger Outputs + +Trigger outputs use the same schema as block outputs (NOT tool outputs). + +**Supported:** +- `type` and `description` for simple fields +- Nested object structure for complex data + +**NOT Supported:** +- `optional: true` (tool outputs only) +- `items` property (tool outputs only) + +```typescript +export function buildOutputs(): Record { + return { + // Simple fields + eventType: { type: 'string', description: 'Event type' }, + timestamp: { type: 'string', description: 'When it occurred' }, + + // Complex data - use type: 'json' + payload: { type: 'json', description: 'Full event payload' }, + + // Nested structure + resource: { + id: { type: 'string', description: 'Resource ID' }, + name: { type: 'string', description: 'Resource name' }, + }, + } +} +``` + +## Generic Webhook Trigger Pattern + +For services with many event types, create a generic webhook that accepts all events: + +```typescript +export const {service}WebhookTrigger: TriggerConfig = { + id: '{service}_webhook', + name: '{Service} Webhook (All Events)', + // ... + + subBlocks: buildTriggerSubBlocks({ + triggerId: '{service}_webhook', + triggerOptions: {service}TriggerOptions, + setupInstructions: {service}SetupInstructions('All Events'), + extraFields: [ + // Event type filter (optional) + { + id: 'eventTypes', + title: 'Event Types', + type: 'dropdown', + multiSelect: true, + options: [ + { label: 'Event A', id: 'event_a' }, + { label: 'Event B', id: 'event_b' }, + ], + placeholder: 'Leave empty for all events', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: '{service}_webhook' }, + }, + // Plus any other service-specific fields + ...build{Service}ExtraFields('{service}_webhook'), + ], + }), +} +``` + +## Checklist Before Finishing + +### Utils +- [ ] Created `{service}TriggerOptions` array with all trigger IDs +- [ ] Created `{service}SetupInstructions` function with clear steps +- [ ] Created `build{Service}ExtraFields` for service-specific fields +- [ ] Created output builders for each trigger type + +### Triggers +- [ ] Primary trigger has `includeDropdown: true` +- [ ] Secondary triggers do NOT have `includeDropdown` +- [ ] All triggers use `buildTriggerSubBlocks` helper +- [ ] All triggers have proper outputs defined +- [ ] Created `index.ts` barrel export + +### Registration +- [ ] All triggers imported in `triggers/registry.ts` +- [ ] All triggers added to `TRIGGER_REGISTRY` +- [ ] Block has `triggers.enabled: true` +- [ ] Block has all trigger IDs in `triggers.available` +- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` + +### Automatic Webhook Registration (if supported) +- [ ] Added API key field to `build{Service}ExtraFields` with `password: true` +- [ ] Updated setup instructions for automatic webhook creation +- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` +- [ ] Added `create{Service}WebhookSubscription` helper function +- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` +- [ ] Added provider to `cleanupExternalWebhook` function + +### Testing +- [ ] Run `bun run type-check` to verify no TypeScript errors +- [ ] Restart dev server to pick up new triggers +- [ ] Test trigger UI shows correctly in the block +- [ ] Test automatic webhook creation works (if applicable) diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index de0ab92021..2c46036dbf 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1853,6 +1853,23 @@ export function LinearIcon(props: React.SVGProps) { ) } +export function LemlistIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function TelegramIcon(props: SVGProps) { return ( = { jira_service_management: JiraServiceManagementIcon, kalshi: KalshiIcon, knowledge: PackageSearchIcon, + lemlist: LemlistIcon, linear: LinearIcon, linkedin: LinkedInIcon, linkup: LinkupIcon, diff --git a/apps/docs/content/docs/en/tools/lemlist.mdx b/apps/docs/content/docs/en/tools/lemlist.mdx new file mode 100644 index 0000000000..c3b38bb720 --- /dev/null +++ b/apps/docs/content/docs/en/tools/lemlist.mdx @@ -0,0 +1,95 @@ +--- +title: Lemlist +description: Manage outreach activities, leads, and send emails via Lemlist +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Lemlist into your workflow. Retrieve campaign activities and replies, get lead information, and send emails through the Lemlist inbox. + + + +## Tools + +### `lemlist_get_activities` + +Retrieves campaign activities and steps performed, including email opens, clicks, replies, and other events. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Lemlist API key | +| `type` | string | No | Filter by activity type \(e.g., emailOpened, emailClicked, emailReplied, paused\) | +| `campaignId` | string | No | Filter by campaign ID | +| `leadId` | string | No | Filter by lead ID | +| `isFirst` | boolean | No | Filter for first activity only | +| `limit` | number | No | Number of results per request \(max 100, default 100\) | +| `offset` | number | No | Number of records to skip for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `activities` | array | List of activities | + +### `lemlist_get_lead` + +Retrieves lead information by email address or lead ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Lemlist API key | +| `email` | string | No | Lead email address \(use either email or id\) | +| `id` | string | No | Lead ID \(use either email or id\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `_id` | string | Lead ID | +| `email` | string | Lead email address | +| `firstName` | string | Lead first name | +| `lastName` | string | Lead last name | +| `companyName` | string | Company name | +| `jobTitle` | string | Job title | +| `companyDomain` | string | Company domain | +| `isPaused` | boolean | Whether the lead is paused | +| `campaignId` | string | Campaign ID the lead belongs to | +| `contactId` | string | Contact ID | +| `emailStatus` | string | Email deliverability status | + +### `lemlist_send_email` + +Sends an email to a contact through the Lemlist inbox. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Lemlist API key | +| `sendUserId` | string | Yes | Identifier for the user sending the message | +| `sendUserEmail` | string | Yes | Email address of the sender | +| `sendUserMailboxId` | string | Yes | Mailbox identifier for the sender | +| `contactId` | string | Yes | Recipient contact identifier | +| `leadId` | string | Yes | Associated lead identifier | +| `subject` | string | Yes | Email subject line | +| `message` | string | Yes | Email message body in HTML format | +| `cc` | json | No | Array of CC email addresses | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `ok` | boolean | Whether the email was sent successfully | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 4bed2269bb..e489efd205 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -51,6 +51,7 @@ "jira_service_management", "kalshi", "knowledge", + "lemlist", "linear", "linkedin", "linkup", diff --git a/apps/sim/app/api/webhooks/[id]/test-url/route.ts b/apps/sim/app/api/webhooks/[id]/test-url/route.ts deleted file mode 100644 index 7b27b2280c..0000000000 --- a/apps/sim/app/api/webhooks/[id]/test-url/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { db, webhook, workflow } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { signTestWebhookToken } from '@/lib/webhooks/test-tokens' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('MintWebhookTestUrlAPI') - -export const dynamic = 'force-dynamic' - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - const body = await request.json().catch(() => ({})) - const ttlSeconds = Math.max( - 60, - Math.min(60 * 60 * 24 * 30, Number(body?.ttlSeconds) || 60 * 60 * 24 * 7) - ) - - // Load webhook + workflow for permission check - const rows = await db - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(eq(webhook.id, id)) - .limit(1) - - if (rows.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - const wf = rows[0].workflow - - // Permissions: owner OR workspace write/admin - let canMint = false - if (wf.userId === session.user.id) { - canMint = true - } else if (wf.workspaceId) { - const perm = await getUserEntityPermissions(session.user.id, 'workspace', wf.workspaceId) - if (perm === 'write' || perm === 'admin') { - canMint = true - } - } - - if (!canMint) { - logger.warn(`[${requestId}] User ${session.user.id} denied mint for webhook ${id}`) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const token = await signTestWebhookToken(id, ttlSeconds) - const url = `${getBaseUrl()}/api/webhooks/test/${id}?token=${encodeURIComponent(token)}` - - logger.info(`[${requestId}] Minted test URL for webhook ${id}`) - return NextResponse.json({ - url, - expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), - }) - } catch (error: any) { - logger.error('Error minting test webhook URL', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index a26fe9933b..4e980646b9 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -793,6 +793,58 @@ export async function POST(request: NextRequest) { } // --- End Grain specific logic --- + // --- Lemlist specific logic --- + if (savedWebhook && provider === 'lemlist') { + logger.info( + `[${requestId}] Lemlist provider detected. Creating Lemlist webhook subscription.` + ) + try { + const lemlistResult = await createLemlistWebhookSubscription( + { + id: savedWebhook.id, + path: savedWebhook.path, + providerConfig: savedWebhook.providerConfig, + }, + requestId + ) + + if (lemlistResult) { + // Update the webhook record with the external Lemlist hook ID + const updatedConfig = { + ...(savedWebhook.providerConfig as Record), + externalId: lemlistResult.id, + } + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, + updatedAt: new Date(), + }) + .where(eq(webhook.id, savedWebhook.id)) + + savedWebhook.providerConfig = updatedConfig + logger.info(`[${requestId}] Successfully created Lemlist webhook`, { + lemlistHookId: lemlistResult.id, + webhookId: savedWebhook.id, + }) + } + } catch (err) { + logger.error( + `[${requestId}] Error creating Lemlist webhook subscription, rolling back webhook`, + err + ) + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + return NextResponse.json( + { + error: 'Failed to create webhook in Lemlist', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } + // --- End Lemlist specific logic --- + if (!targetWebhookId && savedWebhook) { try { PlatformEvents.webhookCreated({ @@ -1316,3 +1368,116 @@ async function createGrainWebhookSubscription( throw error } } + +// Helper function to create the webhook subscription in Lemlist +async function createLemlistWebhookSubscription( + webhookData: any, + requestId: string +): Promise<{ id: string } | undefined> { + try { + const { path, providerConfig } = webhookData + const { apiKey, triggerId, campaignId } = providerConfig || {} + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' + ) + } + + // Map trigger IDs to Lemlist event types + const eventTypeMap: Record = { + lemlist_email_replied: 'emailsReplied', + lemlist_linkedin_replied: 'linkedinReplied', + lemlist_interested: 'interested', + lemlist_not_interested: 'notInterested', + lemlist_email_opened: 'emailsOpened', + lemlist_email_clicked: 'emailsClicked', + lemlist_email_bounced: 'emailsBounced', + lemlist_email_sent: 'emailsSent', + lemlist_webhook: undefined, // Generic webhook - no type filter + } + + const eventType = eventTypeMap[triggerId] + + logger.info(`[${requestId}] Creating Lemlist webhook`, { + triggerId, + eventType, + hasCampaignId: !!campaignId, + webhookId: webhookData.id, + }) + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' + + // Build request body + const requestBody: Record = { + targetUrl: notificationUrl, + } + + // Add event type if specified (omit for generic webhook to receive all events) + if (eventType) { + requestBody.type = eventType + } + + // Add campaign filter if specified + if (campaignId) { + requestBody.campaignId = campaignId + } + + // Lemlist uses Basic Auth with empty username and API key as password + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await lemlistResponse.json() + + if (!lemlistResponse.ok || responseBody.error) { + const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error' + logger.error( + `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' + if (lemlistResponse.status === 401) { + userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' + } else if (lemlistResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { + userFriendlyMessage = `Lemlist error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, + { + lemlistWebhookId: responseBody._id, + } + ) + + return { id: responseBody._id } + } catch (error: any) { + logger.error( + `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + throw error + } +} diff --git a/apps/sim/app/api/webhooks/test/[id]/route.ts b/apps/sim/app/api/webhooks/test/[id]/route.ts deleted file mode 100644 index 46653c3bf7..0000000000 --- a/apps/sim/app/api/webhooks/test/[id]/route.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { - checkWebhookPreprocessing, - findWebhookAndWorkflow, - handleProviderChallenges, - parseWebhookBody, - queueWebhookExecution, - verifyProviderAuth, -} from '@/lib/webhooks/processor' -import { verifyTestWebhookToken } from '@/lib/webhooks/test-tokens' - -const logger = createLogger('WebhookTestReceiverAPI') - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const webhookId = (await params).id - - logger.info(`[${requestId}] Test webhook request received for webhook ${webhookId}`) - - const parseResult = await parseWebhookBody(request, requestId) - if (parseResult instanceof NextResponse) { - return parseResult - } - - const { body, rawBody } = parseResult - - const challengeResponse = await handleProviderChallenges(body, request, requestId, '') - if (challengeResponse) { - return challengeResponse - } - - const url = new URL(request.url) - const token = url.searchParams.get('token') - - if (!token) { - logger.warn(`[${requestId}] Test webhook request missing token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - - const isValid = await verifyTestWebhookToken(token, webhookId) - if (!isValid) { - logger.warn(`[${requestId}] Invalid test webhook token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - - const result = await findWebhookAndWorkflow({ requestId, webhookId }) - if (!result) { - logger.warn(`[${requestId}] No active webhook found for id: ${webhookId}`) - return new NextResponse('Webhook not found', { status: 404 }) - } - - const { webhook: foundWebhook, workflow: foundWorkflow } = result - - const authError = await verifyProviderAuth( - foundWebhook, - foundWorkflow, - request, - rawBody, - requestId - ) - if (authError) { - return authError - } - - let preprocessError: NextResponse | null = null - try { - // Test webhooks skip deployment check but still enforce rate limits and usage limits - // They run on live/draft state to allow testing before deployment - preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId, { - isTestMode: true, - }) - if (preprocessError) { - return preprocessError - } - } catch (error) { - logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - }) - - if (foundWebhook.provider === 'microsoft-teams') { - return NextResponse.json( - { - type: 'message', - text: 'An unexpected error occurred during preprocessing', - }, - { status: 500 } - ) - } - - return NextResponse.json( - { error: 'An unexpected error occurred during preprocessing' }, - { status: 500 } - ) - } - - logger.info( - `[${requestId}] Executing TEST webhook for ${foundWebhook.provider} (workflow: ${foundWorkflow.id})` - ) - - return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - path: foundWebhook.path, - testMode: true, - executionTarget: 'live', - }) -} diff --git a/apps/sim/app/api/webhooks/test/route.ts b/apps/sim/app/api/webhooks/test/route.ts deleted file mode 100644 index bf3aece243..0000000000 --- a/apps/sim/app/api/webhooks/test/route.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { db } from '@sim/db' -import { webhook } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' - -const logger = createLogger('WebhookTestAPI') - -export const dynamic = 'force-dynamic' - -export async function GET(request: NextRequest) { - const requestId = generateRequestId() - - try { - const { searchParams } = new URL(request.url) - const webhookId = searchParams.get('id') - - if (!webhookId) { - logger.warn(`[${requestId}] Missing webhook ID in test request`) - return NextResponse.json({ success: false, error: 'Webhook ID is required' }, { status: 400 }) - } - - logger.debug(`[${requestId}] Testing webhook with ID: ${webhookId}`) - - const webhooks = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) - - if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${webhookId}`) - return NextResponse.json({ success: false, error: 'Webhook not found' }, { status: 404 }) - } - - const foundWebhook = webhooks[0] - const provider = foundWebhook.provider || 'generic' - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${foundWebhook.path}` - - logger.info(`[${requestId}] Testing webhook for provider: ${provider}`, { - webhookId, - path: foundWebhook.path, - isActive: foundWebhook.isActive, - }) - - switch (provider) { - case 'whatsapp': { - const verificationToken = providerConfig.verificationToken - - if (!verificationToken) { - logger.warn(`[${requestId}] WhatsApp webhook missing verification token: ${webhookId}`) - return NextResponse.json( - { success: false, error: 'Webhook has no verification token' }, - { status: 400 } - ) - } - - const challenge = `test_${Date.now()}` - - const whatsappUrl = `${webhookUrl}?hub.mode=subscribe&hub.verify_token=${verificationToken}&hub.challenge=${challenge}` - - logger.debug(`[${requestId}] Testing WhatsApp webhook verification`, { - webhookId, - challenge, - }) - - const response = await fetch(whatsappUrl, { - headers: { - 'User-Agent': 'facebookplatform/1.0', - }, - }) - - const status = response.status - const contentType = response.headers.get('content-type') - const responseText = await response.text() - - const success = status === 200 && responseText === challenge - - if (success) { - logger.info(`[${requestId}] WhatsApp webhook verification successful: ${webhookId}`) - } else { - logger.warn(`[${requestId}] WhatsApp webhook verification failed: ${webhookId}`, { - status, - contentType, - responseTextLength: responseText.length, - }) - } - - return NextResponse.json({ - success, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - verificationToken, - isActive: foundWebhook.isActive, - }, - test: { - status, - contentType, - responseText, - expectedStatus: 200, - expectedContentType: 'text/plain', - expectedResponse: challenge, - }, - message: success - ? 'Webhook configuration is valid. You can now use this URL in WhatsApp.' - : 'Webhook verification failed. Please check your configuration.', - diagnostics: { - statusMatch: status === 200 ? '✅ Status code is 200' : '❌ Status code should be 200', - contentTypeMatch: - contentType === 'text/plain' - ? '✅ Content-Type is text/plain' - : '❌ Content-Type should be text/plain', - bodyMatch: - responseText === challenge - ? '✅ Response body matches challenge' - : '❌ Response body should exactly match the challenge string', - }, - }) - } - - case 'telegram': { - const botToken = providerConfig.botToken - - if (!botToken) { - logger.warn(`[${requestId}] Telegram webhook missing configuration: ${webhookId}`) - return NextResponse.json( - { success: false, error: 'Webhook has incomplete configuration' }, - { status: 400 } - ) - } - - const testMessage = { - update_id: 12345, - message: { - message_id: 67890, - from: { - id: 123456789, - first_name: 'Test', - username: 'testbot', - }, - chat: { - id: 123456789, - first_name: 'Test', - username: 'testbot', - type: 'private', - }, - date: Math.floor(Date.now() / 1000), - text: 'This is a test message', - }, - } - - logger.debug(`[${requestId}] Testing Telegram webhook connection`, { - webhookId, - url: webhookUrl, - }) - - const response = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', - }, - body: JSON.stringify(testMessage), - }) - - const status = response.status - let responseText = '' - try { - responseText = await response.text() - } catch (_e) {} - - const success = status >= 200 && status < 300 - - if (success) { - logger.info(`[${requestId}] Telegram webhook test successful: ${webhookId}`) - } else { - logger.warn(`[${requestId}] Telegram webhook test failed: ${webhookId}`, { - status, - responseText, - }) - } - - let webhookInfo = null - try { - const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo` - const infoResponse = await fetch(webhookInfoUrl, { - headers: { - 'User-Agent': 'TelegramBot/1.0', - }, - }) - if (infoResponse.ok) { - const infoJson = await infoResponse.json() - if (infoJson.ok) { - webhookInfo = infoJson.result - } - } - } catch (e) { - logger.warn(`[${requestId}] Failed to get Telegram webhook info`, e) - } - - const curlCommand = [ - `curl -X POST "${webhookUrl}"`, - `-H "Content-Type: application/json"`, - `-H "User-Agent: TelegramBot/1.0"`, - `-d '${JSON.stringify(testMessage, null, 2)}'`, - ].join(' \\\n') - - return NextResponse.json({ - success, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - botToken: `${botToken.substring(0, 5)}...${botToken.substring(botToken.length - 5)}`, // Show partial token for security - isActive: foundWebhook.isActive, - }, - test: { - status, - responseText, - webhookInfo, - }, - message: success - ? 'Telegram webhook appears to be working. Any message sent to your bot will trigger the workflow.' - : 'Telegram webhook test failed. Please check server logs for more details.', - curlCommand, - info: 'To fix issues with Telegram webhooks getting 403 Forbidden responses, ensure the webhook request includes a User-Agent header.', - }) - } - - case 'github': { - const contentType = providerConfig.contentType || 'application/json' - - logger.info(`[${requestId}] GitHub webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - contentType, - isActive: foundWebhook.isActive, - }, - message: - 'GitHub webhook configuration is valid. Use this URL in your GitHub repository settings.', - setup: { - url: webhookUrl, - contentType, - events: ['push', 'pull_request', 'issues', 'issue_comment'], - }, - }) - } - - case 'stripe': { - logger.info(`[${requestId}] Stripe webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - isActive: foundWebhook.isActive, - }, - message: 'Stripe webhook configuration is valid. Use this URL in your Stripe dashboard.', - setup: { - url: webhookUrl, - events: [ - 'charge.succeeded', - 'invoice.payment_succeeded', - 'customer.subscription.created', - ], - }, - }) - } - - case 'generic': { - const token = providerConfig.token - const secretHeaderName = providerConfig.secretHeaderName - const requireAuth = providerConfig.requireAuth - const allowedIps = providerConfig.allowedIps - - let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"` - - if (requireAuth && token) { - if (secretHeaderName) { - curlCommand += ` -H "${secretHeaderName}: ${token}"` - } else { - curlCommand += ` -H "Authorization: Bearer ${token}"` - } - } - - curlCommand += ` -d '{"event":"test_event","timestamp":"${new Date().toISOString()}"}'` - - logger.info(`[${requestId}] General webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - isActive: foundWebhook.isActive, - }, - message: - 'General webhook configuration is valid. Use the URL and authentication details as needed.', - details: { - requireAuth: requireAuth || false, - hasToken: !!token, - hasCustomHeader: !!secretHeaderName, - customHeaderName: secretHeaderName, - hasIpRestrictions: Array.isArray(allowedIps) && allowedIps.length > 0, - }, - test: { - curlCommand, - headers: requireAuth - ? secretHeaderName - ? { [secretHeaderName]: token } - : { Authorization: `Bearer ${token}` } - : {}, - samplePayload: { - event: 'test_event', - timestamp: new Date().toISOString(), - }, - }, - }) - } - - case 'slack': { - const signingSecret = providerConfig.signingSecret - - if (!signingSecret) { - logger.warn(`[${requestId}] Slack webhook missing signing secret: ${webhookId}`) - return NextResponse.json( - { success: false, error: 'Webhook has no signing secret configured' }, - { status: 400 } - ) - } - - logger.info(`[${requestId}] Slack webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - isActive: foundWebhook.isActive, - }, - message: - 'Slack webhook configuration is valid. Use this URL in your Slack Event Subscriptions settings.', - setup: { - url: webhookUrl, - events: ['message.channels', 'reaction_added', 'app_mention'], - signingSecretConfigured: true, - }, - test: { - curlCommand: [ - `curl -X POST "${webhookUrl}"`, - `-H "Content-Type: application/json"`, - `-H "X-Slack-Request-Timestamp: $(date +%s)"`, - `-H "X-Slack-Signature: v0=$(date +%s)"`, - `-d '{"type":"event_callback","event":{"type":"message","channel":"C0123456789","user":"U0123456789","text":"Hello from Slack!","ts":"1234567890.123456"},"team_id":"T0123456789"}'`, - ].join(' \\\n'), - samplePayload: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'T123ABC', - event: { - type: 'message', - user: 'U123ABC', - text: 'Hello from Slack!', - ts: '1234567890.1234', - }, - event_id: 'Ev123ABC', - }, - }, - }) - } - - case 'airtable': { - const baseId = providerConfig.baseId - const tableId = providerConfig.tableId - const webhookSecret = providerConfig.webhookSecret - - if (!baseId || !tableId) { - logger.warn(`[${requestId}] Airtable webhook missing Base ID or Table ID: ${webhookId}`) - return NextResponse.json( - { - success: false, - error: 'Webhook configuration is incomplete (missing Base ID or Table ID)', - }, - { status: 400 } - ) - } - - const samplePayload = { - webhook: { - id: 'whiYOUR_WEBHOOK_ID', - }, - base: { - id: baseId, - }, - payloadFormat: 'v0', - actionMetadata: { - source: 'tableOrViewChange', - sourceMetadata: {}, - }, - payloads: [ - { - timestamp: new Date().toISOString(), - baseTransactionNumber: Date.now(), - changedTablesById: { - [tableId]: { - changedRecordsById: { - recSAMPLEID1: { - current: { cellValuesByFieldId: { fldSAMPLEID: 'New Value' } }, - previous: { cellValuesByFieldId: { fldSAMPLEID: 'Old Value' } }, - }, - }, - changedFieldsById: {}, - changedViewsById: {}, - }, - }, - }, - ], - } - - let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"` - curlCommand += ` -d '${JSON.stringify(samplePayload, null, 2)}'` - - logger.info(`[${requestId}] Airtable webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - baseId: baseId, - tableId: tableId, - secretConfigured: !!webhookSecret, - isActive: foundWebhook.isActive, - }, - message: - 'Airtable webhook configuration appears valid. Use the sample curl command to manually send a test payload to your webhook URL.', - test: { - curlCommand: curlCommand, - samplePayload: samplePayload, - }, - }) - } - - case 'microsoft-teams': { - const hmacSecret = providerConfig.hmacSecret - - if (!hmacSecret) { - logger.warn(`[${requestId}] Microsoft Teams webhook missing HMAC secret: ${webhookId}`) - return NextResponse.json( - { success: false, error: 'Microsoft Teams webhook requires HMAC secret' }, - { status: 400 } - ) - } - - logger.info(`[${requestId}] Microsoft Teams webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - isActive: foundWebhook.isActive, - }, - message: 'Microsoft Teams outgoing webhook configuration is valid.', - setup: { - url: webhookUrl, - hmacSecretConfigured: !!hmacSecret, - instructions: [ - 'Create an outgoing webhook in Microsoft Teams', - 'Set the callback URL to the webhook URL above', - 'Copy the HMAC security token to the configuration', - 'Users can trigger the webhook by @mentioning it in Teams', - ], - }, - test: { - curlCommand: `curl -X POST "${webhookUrl}" \\ - -H "Content-Type: application/json" \\ - -H "Authorization: HMAC " \\ - -d '{"type":"message","text":"Hello from Microsoft Teams!","from":{"id":"test","name":"Test User"}}'`, - samplePayload: { - type: 'message', - id: '1234567890', - timestamp: new Date().toISOString(), - text: 'Hello Sim Bot!', - from: { - id: '29:1234567890abcdef', - name: 'Test User', - }, - conversation: { - id: '19:meeting_abcdef@thread.v2', - }, - }, - }, - }) - } - - default: { - logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`) - return NextResponse.json({ - success: true, - webhook: { - id: foundWebhook.id, - url: webhookUrl, - provider: foundWebhook.provider, - isActive: foundWebhook.isActive, - }, - message: - 'Webhook configuration is valid. You can use this URL to receive webhook events.', - }) - } - } - } catch (error: any) { - logger.error(`[${requestId}] Error testing webhook`, error) - return NextResponse.json( - { - success: false, - error: 'Test failed', - message: error.message, - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index f3aba1e7b4..ae11e476cf 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -152,7 +152,6 @@ export async function POST( const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { requestId, path, - testMode: false, executionTarget: 'deployed', }) responses.push(response) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx index b66463ca95..4fb9e306aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx @@ -16,7 +16,6 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' -import { ShortInput } from '../short-input/short-input' const logger = createLogger('TriggerSave') @@ -41,22 +40,6 @@ export function TriggerSave({ const [errorMessage, setErrorMessage] = useState(null) const [deleteStatus, setDeleteStatus] = useState<'idle' | 'deleting'>('idle') const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false) - - const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as - | string - | null - const storedTestUrlExpiresAt = useSubBlockStore((state) => - state.getValue(blockId, 'testUrlExpiresAt') - ) as string | null - - const isTestUrlExpired = useMemo(() => { - if (!storedTestUrlExpiresAt) return true - return new Date(storedTestUrlExpiresAt) < new Date() - }, [storedTestUrlExpiresAt]) - - const testUrl = isTestUrlExpired ? null : (storedTestUrl as string | null) - const testUrlExpiresAt = isTestUrlExpired ? null : (storedTestUrlExpiresAt as string | null) const effectiveTriggerId = useMemo(() => { if (triggerId && isTriggerValid(triggerId)) { @@ -86,9 +69,6 @@ export function TriggerSave({ const triggerDef = effectiveTriggerId && isTriggerValid(effectiveTriggerId) ? getTrigger(effectiveTriggerId) : null - const hasWebhookUrlDisplay = - triggerDef?.subBlocks.some((sb) => sb.id === 'webhookUrlDisplay') ?? false - const validateRequiredFields = useCallback( ( configToCheck: Record | null | undefined @@ -212,13 +192,6 @@ export function TriggerSave({ validateRequiredFields, ]) - useEffect(() => { - if (isTestUrlExpired && storedTestUrl) { - useSubBlockStore.getState().setValue(blockId, 'testUrl', null) - useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null) - } - }, [blockId, isTestUrlExpired, storedTestUrl]) - const handleSave = async () => { if (isPreview || disabled) return @@ -278,34 +251,6 @@ export function TriggerSave({ } } - const generateTestUrl = async () => { - if (!webhookId) return - try { - setIsGeneratingTestUrl(true) - const res = await fetch(`/api/webhooks/${webhookId}/test-url`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err?.error || 'Failed to generate test URL') - } - const json = await res.json() - useSubBlockStore.getState().setValue(blockId, 'testUrl', json.url) - useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', json.expiresAt) - collaborativeSetSubblockValue(blockId, 'testUrl', json.url) - collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', json.expiresAt) - } catch (e) { - logger.error('Failed to generate test webhook URL', { error: e }) - setErrorMessage( - e instanceof Error ? e.message : 'Failed to generate test URL. Please try again.' - ) - } finally { - setIsGeneratingTestUrl(false) - } - } - const handleDeleteClick = () => { if (isPreview || disabled || !webhookId) return setShowDeleteDialog(true) @@ -324,14 +269,9 @@ export function TriggerSave({ setSaveStatus('idle') setErrorMessage(null) - useSubBlockStore.getState().setValue(blockId, 'testUrl', null) - useSubBlockStore.getState().setValue(blockId, 'testUrlExpiresAt', null) - collaborativeSetSubblockValue(blockId, 'triggerPath', '') collaborativeSetSubblockValue(blockId, 'webhookId', null) collaborativeSetSubblockValue(blockId, 'triggerConfig', null) - collaborativeSetSubblockValue(blockId, 'testUrl', null) - collaborativeSetSubblockValue(blockId, 'testUrlExpiresAt', null) logger.info('Trigger configuration deleted successfully', { blockId, @@ -383,51 +323,6 @@ export function TriggerSave({ {errorMessage &&

{errorMessage}

} - {webhookId && hasWebhookUrlDisplay && ( -
-
- - Test Webhook URL - - -
- {testUrl ? ( - <> - - {testUrlExpiresAt && ( -

- Expires {new Date(testUrlExpiresAt).toLocaleString()} -

- )} - - ) : ( -

- Generate a temporary URL to test against the live (undeployed) workflow state. -

- )} -
- )} - Delete Trigger diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index b0447e8a00..55cb3e0300 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -92,7 +92,6 @@ export type WebhookExecutionPayload = { headers: Record path: string blockId?: string - testMode?: boolean executionTarget?: 'deployed' | 'live' credentialId?: string credentialAccountUserId?: string @@ -318,7 +317,7 @@ async function executeWebhookJobInternal( workspaceId, variables: {}, triggerData: { - isTest: payload.testMode === true, + isTest: false, executionTarget: payload.executionTarget || 'deployed', }, deploymentVersionId, @@ -376,7 +375,7 @@ async function executeWebhookJobInternal( workspaceId, variables: {}, triggerData: { - isTest: payload.testMode === true, + isTest: false, executionTarget: payload.executionTarget || 'deployed', }, deploymentVersionId, @@ -595,7 +594,7 @@ async function executeWebhookJobInternal( workspaceId: errorWorkspaceId, variables: {}, triggerData: { - isTest: payload.testMode === true, + isTest: false, executionTarget: payload.executionTarget || 'deployed', }, deploymentVersionId, diff --git a/apps/sim/blocks/blocks/lemlist.ts b/apps/sim/blocks/blocks/lemlist.ts new file mode 100644 index 0000000000..21dd7b212e --- /dev/null +++ b/apps/sim/blocks/blocks/lemlist.ts @@ -0,0 +1,240 @@ +import { LemlistIcon } from '@/components/icons' +import { AuthMode, type BlockConfig } from '@/blocks/types' +import type { LemlistResponse } from '@/tools/lemlist/types' +import { getTrigger } from '@/triggers' + +export const LemlistBlock: BlockConfig = { + type: 'lemlist', + name: 'Lemlist', + description: 'Manage outreach activities, leads, and send emails via Lemlist', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Lemlist into your workflow. Retrieve campaign activities and replies, get lead information, and send emails through the Lemlist inbox.', + docsLink: 'https://docs.sim.ai/tools/lemlist', + category: 'tools', + bgColor: '#316BFF', + icon: LemlistIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get Activities', id: 'get_activities' }, + { label: 'Get Lead', id: 'get_lead' }, + { label: 'Send Email', id: 'send_email' }, + ], + value: () => 'get_activities', + }, + { + id: 'type', + title: 'Activity Type', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Email Opened', id: 'emailOpened' }, + { label: 'Email Clicked', id: 'emailClicked' }, + { label: 'Email Replied', id: 'emailReplied' }, + { label: 'Email Sent', id: 'emailsSent' }, + { label: 'Email Bounced', id: 'emailsBounced' }, + { label: 'Paused', id: 'paused' }, + { label: 'Interested', id: 'interested' }, + { label: 'Not Interested', id: 'notInterested' }, + ], + value: () => '', + condition: { field: 'operation', value: 'get_activities' }, + }, + { + id: 'campaignId', + title: 'Campaign ID', + type: 'short-input', + placeholder: 'Filter by campaign ID (optional)', + condition: { field: 'operation', value: 'get_activities' }, + }, + { + id: 'filterLeadId', + title: 'Lead ID', + type: 'short-input', + placeholder: 'Filter by lead ID (optional)', + condition: { field: 'operation', value: 'get_activities' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100 (max)', + condition: { field: 'operation', value: 'get_activities' }, + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'get_activities' }, + }, + { + id: 'email', + title: 'Email Address', + type: 'short-input', + placeholder: 'Enter lead email address', + condition: { field: 'operation', value: 'get_lead' }, + mode: 'basic', + canonicalParamId: 'leadIdentifier', + }, + { + id: 'leadIdLookup', + title: 'Lead ID', + type: 'short-input', + placeholder: 'Enter lead ID', + condition: { field: 'operation', value: 'get_lead' }, + mode: 'advanced', + canonicalParamId: 'leadIdentifier', + }, + { + id: 'sendUserId', + title: 'Sender User ID', + type: 'short-input', + placeholder: 'Your Lemlist user ID', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'sendUserEmail', + title: 'Sender Email', + type: 'short-input', + placeholder: 'Your email address', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'sendUserMailboxId', + title: 'Mailbox ID', + type: 'short-input', + placeholder: 'Your mailbox ID', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'contactId', + title: 'Contact ID', + type: 'short-input', + placeholder: 'Recipient contact ID', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'leadId', + title: 'Lead ID', + type: 'short-input', + placeholder: 'Associated lead ID', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'subject', + title: 'Subject', + type: 'short-input', + placeholder: 'Email subject', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'message', + title: 'Message', + type: 'long-input', + placeholder: 'Email message body (HTML supported)', + required: { field: 'operation', value: 'send_email' }, + condition: { field: 'operation', value: 'send_email' }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Lemlist API key', + password: true, + }, + // Trigger subBlocks - first trigger has dropdown, others don't + ...getTrigger('lemlist_email_replied').subBlocks, + ...getTrigger('lemlist_linkedin_replied').subBlocks, + ...getTrigger('lemlist_interested').subBlocks, + ...getTrigger('lemlist_not_interested').subBlocks, + ...getTrigger('lemlist_email_opened').subBlocks, + ...getTrigger('lemlist_email_clicked').subBlocks, + ...getTrigger('lemlist_email_bounced').subBlocks, + ...getTrigger('lemlist_email_sent').subBlocks, + ...getTrigger('lemlist_webhook').subBlocks, + ], + tools: { + access: ['lemlist_get_activities', 'lemlist_get_lead', 'lemlist_send_email'], + config: { + tool: (params) => { + if (params.limit) { + params.limit = Number(params.limit) + } + if (params.offset) { + params.offset = Number(params.offset) + } + // Map filterLeadId to leadId for get_activities tool + if (params.filterLeadId) { + params.leadId = params.filterLeadId + } + + switch (params.operation) { + case 'get_activities': + return 'lemlist_get_activities' + case 'get_lead': + return 'lemlist_get_lead' + case 'send_email': + return 'lemlist_send_email' + default: + return 'lemlist_get_activities' + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Lemlist API key' }, + type: { type: 'string', description: 'Activity type filter' }, + campaignId: { type: 'string', description: 'Campaign ID filter' }, + filterLeadId: { type: 'string', description: 'Lead ID filter for activities' }, + leadId: { type: 'string', description: 'Lead ID for send email' }, + limit: { type: 'number', description: 'Result limit' }, + offset: { type: 'number', description: 'Result offset' }, + leadIdentifier: { type: 'string', description: 'Lead email address or ID' }, + sendUserId: { type: 'string', description: 'Sender user ID' }, + sendUserEmail: { type: 'string', description: 'Sender email address' }, + sendUserMailboxId: { type: 'string', description: 'Sender mailbox ID' }, + contactId: { type: 'string', description: 'Recipient contact ID' }, + subject: { type: 'string', description: 'Email subject' }, + message: { type: 'string', description: 'Email message body' }, + }, + outputs: { + activities: { type: 'json', description: 'List of campaign activities' }, + count: { type: 'number', description: 'Number of activities returned' }, + _id: { type: 'string', description: 'Lead ID' }, + email: { type: 'string', description: 'Lead email' }, + firstName: { type: 'string', description: 'Lead first name' }, + lastName: { type: 'string', description: 'Lead last name' }, + companyName: { type: 'string', description: 'Company name' }, + jobTitle: { type: 'string', description: 'Job title' }, + isPaused: { type: 'boolean', description: 'Whether lead is paused' }, + emailStatus: { type: 'string', description: 'Email deliverability status' }, + ok: { type: 'boolean', description: 'Whether email was sent successfully' }, + }, + triggers: { + enabled: true, + available: [ + 'lemlist_email_replied', + 'lemlist_linkedin_replied', + 'lemlist_interested', + 'lemlist_not_interested', + 'lemlist_email_opened', + 'lemlist_email_clicked', + 'lemlist_email_bounced', + 'lemlist_email_sent', + 'lemlist_webhook', + ], + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index e3ad607250..85a5b7ac2f 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -60,6 +60,7 @@ import { JiraBlock } from '@/blocks/blocks/jira' import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management' import { KalshiBlock } from '@/blocks/blocks/kalshi' import { KnowledgeBlock } from '@/blocks/blocks/knowledge' +import { LemlistBlock } from '@/blocks/blocks/lemlist' import { LinearBlock } from '@/blocks/blocks/linear' import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkupBlock } from '@/blocks/blocks/linkup' @@ -213,6 +214,7 @@ export const registry: Record = { jira_service_management: JiraServiceManagementBlock, kalshi: KalshiBlock, knowledge: KnowledgeBlock, + lemlist: LemlistBlock, linear: LinearBlock, linkedin: LinkedInBlock, linkup: LinkupBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index de0ab92021..91803c3316 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1853,6 +1853,31 @@ export function LinearIcon(props: React.SVGProps) { ) } +export function LemlistIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function TelegramIcon(props: SVGProps) { return ( { - // Skip runtime subblock IDs (webhookId, triggerPath, testUrl, testUrlExpiresAt) + // Skip runtime subblock IDs (webhookId, triggerPath) if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { return } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 5d7d847658..7a70705d41 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -25,7 +25,6 @@ export interface WebhookProcessorOptions { requestId: string path?: string webhookId?: string - testMode?: boolean executionTarget?: 'deployed' | 'live' } @@ -733,17 +732,12 @@ export async function verifyProviderAuth( /** * Run preprocessing checks for webhook execution * This replaces the old checkRateLimits and checkUsageLimits functions - * - * @param isTestMode - If true, skips deployment check (for test webhooks that run on live/draft state) */ export async function checkWebhookPreprocessing( foundWorkflow: any, foundWebhook: any, - requestId: string, - options?: { isTestMode?: boolean } + requestId: string ): Promise { - const { isTestMode = false } = options || {} - try { const executionId = uuidv4() @@ -753,8 +747,8 @@ export async function checkWebhookPreprocessing( triggerType: 'webhook', executionId, requestId, - checkRateLimit: true, // Webhooks need rate limiting - checkDeployment: !isTestMode, // Test webhooks skip deployment check (run on live state) + checkRateLimit: true, + checkDeployment: true, workspaceId: foundWorkflow.workspaceId, }) @@ -954,7 +948,6 @@ export async function queueWebhookExecution( headers, path: options.path || foundWebhook.path, blockId: foundWebhook.blockId, - testMode: options.testMode, executionTarget: options.executionTarget, ...(credentialId ? { credentialId } : {}), } @@ -962,18 +955,14 @@ export async function queueWebhookExecution( if (isTriggerDevEnabled) { const handle = await tasks.trigger('webhook-execution', payload) logger.info( - `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution task ${ - handle.id - } for ${foundWebhook.provider} webhook` + `[${options.requestId}] Queued webhook execution task ${handle.id} for ${foundWebhook.provider} webhook` ) } else { void executeWebhookJob(payload).catch((error) => { logger.error(`[${options.requestId}] Direct webhook execution failed`, error) }) logger.info( - `[${options.requestId}] Queued direct ${ - options.testMode ? 'TEST ' : '' - }webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` + `[${options.requestId}] Queued direct webhook execution for ${foundWebhook.provider} webhook (Trigger.dev disabled)` ) } diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 7b45669353..d7782a7ce3 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -9,6 +9,7 @@ const airtableLogger = createLogger('AirtableWebhook') const typeformLogger = createLogger('TypeformWebhook') const calendlyLogger = createLogger('CalendlyWebhook') const grainLogger = createLogger('GrainWebhook') +const lemlistLogger = createLogger('LemlistWebhook') function getProviderConfig(webhook: any): Record { return (webhook.providerConfig as Record) || {} @@ -711,9 +712,58 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi } } +/** + * Delete a Lemlist webhook + * Don't fail webhook deletion if cleanup fails + */ +export async function deleteLemlistWebhook(webhook: any, requestId: string): Promise { + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + lemlistLogger.warn( + `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + lemlistLogger.warn( + `[${requestId}] Missing externalId for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + // Lemlist uses Basic Auth with empty username and API key as password + const authString = Buffer.from(`:${apiKey}`).toString('base64') + const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${externalId}` + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!lemlistResponse.ok && lemlistResponse.status !== 404) { + const responseBody = await lemlistResponse.json().catch(() => ({})) + lemlistLogger.warn( + `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + { response: responseBody } + ) + } else { + lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${externalId}`) + } + } catch (error) { + lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + } +} + /** * Clean up external webhook subscriptions for a webhook - * Handles Airtable, Teams, Telegram, Typeform, Calendly, and Grain cleanup + * Handles Airtable, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup * Don't fail deletion if cleanup fails */ export async function cleanupExternalWebhook( @@ -733,5 +783,7 @@ export async function cleanupExternalWebhook( await deleteCalendlyWebhook(webhook, requestId) } else if (webhook.provider === 'grain') { await deleteGrainWebhook(webhook, requestId) + } else if (webhook.provider === 'lemlist') { + await deleteLemlistWebhook(webhook, requestId) } } diff --git a/apps/sim/lib/webhooks/test-tokens.ts b/apps/sim/lib/webhooks/test-tokens.ts deleted file mode 100644 index 26495bde1e..0000000000 --- a/apps/sim/lib/webhooks/test-tokens.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { jwtVerify, SignJWT } from 'jose' -import { env } from '@/lib/core/config/env' - -type TestTokenPayload = { - typ: 'webhook_test' - wid: string -} - -const getSecretKey = () => new TextEncoder().encode(env.INTERNAL_API_SECRET) - -export async function signTestWebhookToken(webhookId: string, ttlSeconds: number): Promise { - const secret = getSecretKey() - const payload: TestTokenPayload = { typ: 'webhook_test', wid: webhookId } - - const token = await new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(`${ttlSeconds}s`) - .setIssuer('sim-webhooks') - .setAudience('sim-test') - .sign(secret) - - return token -} - -export async function verifyTestWebhookToken( - token: string, - expectedWebhookId: string -): Promise { - try { - const secret = getSecretKey() - const { payload } = await jwtVerify(token, secret, { - issuer: 'sim-webhooks', - audience: 'sim-test', - }) - - if ( - payload && - (payload as any).typ === 'webhook_test' && - (payload as any).wid === expectedWebhookId - ) { - return true - } - return false - } catch (_e) { - return false - } -} diff --git a/apps/sim/lib/workflows/comparison/compare.test.ts b/apps/sim/lib/workflows/comparison/compare.test.ts index 9b72d30c52..d0abb00409 100644 --- a/apps/sim/lib/workflows/comparison/compare.test.ts +++ b/apps/sim/lib/workflows/comparison/compare.test.ts @@ -2340,62 +2340,6 @@ describe('hasWorkflowChanged', () => { expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) }) - it.concurrent('should not detect change when testUrl differs', () => { - const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1', { - type: 'starter', - subBlocks: { - triggerConfig: { value: { event: 'push' } }, - testUrl: { value: null }, - }, - }), - }, - }) - - const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1', { - type: 'starter', - subBlocks: { - triggerConfig: { value: { event: 'push' } }, - testUrl: { value: 'https://test.example.com/webhook' }, - }, - }), - }, - }) - - expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) - }) - - it.concurrent('should not detect change when testUrlExpiresAt differs', () => { - const deployedState = createWorkflowState({ - blocks: { - block1: createBlock('block1', { - type: 'starter', - subBlocks: { - triggerConfig: { value: { event: 'push' } }, - testUrlExpiresAt: { value: null }, - }, - }), - }, - }) - - const currentState = createWorkflowState({ - blocks: { - block1: createBlock('block1', { - type: 'starter', - subBlocks: { - triggerConfig: { value: { event: 'push' } }, - testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' }, - }, - }), - }, - }) - - expect(hasWorkflowChanged(currentState, deployedState)).toBe(false) - }) - it.concurrent('should not detect change when all runtime metadata differs', () => { const deployedState = createWorkflowState({ blocks: { @@ -2405,8 +2349,6 @@ describe('hasWorkflowChanged', () => { triggerConfig: { value: { event: 'push' } }, webhookId: { value: null }, triggerPath: { value: '' }, - testUrl: { value: null }, - testUrlExpiresAt: { value: null }, }, }), }, @@ -2420,8 +2362,6 @@ describe('hasWorkflowChanged', () => { triggerConfig: { value: { event: 'push' } }, webhookId: { value: 'wh_123456' }, triggerPath: { value: '/api/webhooks/abc123' }, - testUrl: { value: 'https://test.example.com/webhook' }, - testUrlExpiresAt: { value: '2025-12-31T23:59:59Z' }, }, }), }, diff --git a/apps/sim/tools/lemlist/get_activities.ts b/apps/sim/tools/lemlist/get_activities.ts new file mode 100644 index 0000000000..ee4f47d4c7 --- /dev/null +++ b/apps/sim/tools/lemlist/get_activities.ts @@ -0,0 +1,131 @@ +import type { + LemlistGetActivitiesParams, + LemlistGetActivitiesResponse, +} from '@/tools/lemlist/types' +import type { ToolConfig } from '@/tools/types' + +export const getActivitiesTool: ToolConfig< + LemlistGetActivitiesParams, + LemlistGetActivitiesResponse +> = { + id: 'lemlist_get_activities', + name: 'Lemlist Get Activities', + description: + 'Retrieves campaign activities and steps performed, including email opens, clicks, replies, and other events.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Lemlist API key', + }, + type: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Filter by activity type (e.g., emailOpened, emailClicked, emailReplied, paused)', + }, + campaignId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by campaign ID', + }, + leadId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by lead ID', + }, + isFirst: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Filter for first activity only', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per request (max 100, default 100)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of records to skip for pagination', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.lemlist.com/api/activities') + url.searchParams.append('version', 'v2') + + if (params.type) url.searchParams.append('type', params.type) + if (params.campaignId) url.searchParams.append('campaignId', params.campaignId) + if (params.leadId) url.searchParams.append('leadId', params.leadId) + if (params.isFirst !== undefined) url.searchParams.append('isFirst', String(params.isFirst)) + if (params.limit !== undefined) url.searchParams.append('limit', String(params.limit)) + if (params.offset !== undefined) url.searchParams.append('offset', String(params.offset)) + + return url.toString() + }, + method: 'GET', + headers: (params) => { + const credentials = Buffer.from(`:${params.apiKey}`).toString('base64') + return { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + const activities = Array.isArray(data) ? data : [] + + return { + success: true, + output: { + activities: activities.map((activity: Record) => ({ + _id: (activity._id as string) ?? '', + type: (activity.type as string) ?? '', + leadId: (activity.leadId as string) ?? '', + campaignId: (activity.campaignId as string) ?? '', + sequenceId: (activity.sequenceId as string) ?? null, + stepId: (activity.stepId as string) ?? null, + createdAt: (activity.createdAt as string) ?? '', + })), + count: activities.length, + }, + } + }, + + outputs: { + activities: { + type: 'array', + description: 'List of activities', + items: { + type: 'object', + properties: { + _id: { type: 'string', description: 'Activity ID' }, + type: { type: 'string', description: 'Activity type' }, + leadId: { type: 'string', description: 'Associated lead ID' }, + campaignId: { type: 'string', description: 'Campaign ID' }, + sequenceId: { type: 'string', description: 'Sequence ID', optional: true }, + stepId: { type: 'string', description: 'Step ID', optional: true }, + createdAt: { type: 'string', description: 'When the activity occurred' }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of activities returned', + }, + }, +} diff --git a/apps/sim/tools/lemlist/get_lead.ts b/apps/sim/tools/lemlist/get_lead.ts new file mode 100644 index 0000000000..69ff479c58 --- /dev/null +++ b/apps/sim/tools/lemlist/get_lead.ts @@ -0,0 +1,119 @@ +import type { LemlistGetLeadParams, LemlistGetLeadResponse } from '@/tools/lemlist/types' +import type { ToolConfig } from '@/tools/types' + +export const getLeadTool: ToolConfig = { + id: 'lemlist_get_lead', + name: 'Lemlist Get Lead', + description: 'Retrieves lead information by email address or lead ID.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Lemlist API key', + }, + leadIdentifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lead email address or lead ID', + }, + }, + + request: { + url: (params) => { + const identifier = params.leadIdentifier || '' + const isEmail = identifier.includes('@') + if (isEmail) { + return `https://api.lemlist.com/api/leads/${encodeURIComponent(identifier)}` + } + return `https://api.lemlist.com/api/leads?id=${encodeURIComponent(identifier)}` + }, + method: 'GET', + headers: (params) => { + const credentials = Buffer.from(`:${params.apiKey}`).toString('base64') + return { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + _id: data._id ?? '', + email: data.email ?? '', + firstName: data.firstName ?? null, + lastName: data.lastName ?? null, + companyName: data.companyName ?? null, + jobTitle: data.jobTitle ?? null, + companyDomain: data.companyDomain ?? null, + isPaused: data.isPaused ?? false, + campaignId: data.campaignId ?? null, + contactId: data.contactId ?? null, + emailStatus: data.emailStatus ?? null, + }, + } + }, + + outputs: { + _id: { + type: 'string', + description: 'Lead ID', + }, + email: { + type: 'string', + description: 'Lead email address', + }, + firstName: { + type: 'string', + description: 'Lead first name', + optional: true, + }, + lastName: { + type: 'string', + description: 'Lead last name', + optional: true, + }, + companyName: { + type: 'string', + description: 'Company name', + optional: true, + }, + jobTitle: { + type: 'string', + description: 'Job title', + optional: true, + }, + companyDomain: { + type: 'string', + description: 'Company domain', + optional: true, + }, + isPaused: { + type: 'boolean', + description: 'Whether the lead is paused', + }, + campaignId: { + type: 'string', + description: 'Campaign ID the lead belongs to', + optional: true, + }, + contactId: { + type: 'string', + description: 'Contact ID', + optional: true, + }, + emailStatus: { + type: 'string', + description: 'Email deliverability status', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/lemlist/index.ts b/apps/sim/tools/lemlist/index.ts new file mode 100644 index 0000000000..33b7984e7a --- /dev/null +++ b/apps/sim/tools/lemlist/index.ts @@ -0,0 +1,4 @@ +export { getActivitiesTool as lemlistGetActivitiesTool } from '@/tools/lemlist/get_activities' +export { getLeadTool as lemlistGetLeadTool } from '@/tools/lemlist/get_lead' +export { sendEmailTool as lemlistSendEmailTool } from '@/tools/lemlist/send_email' +export * from './types' diff --git a/apps/sim/tools/lemlist/send_email.ts b/apps/sim/tools/lemlist/send_email.ts new file mode 100644 index 0000000000..e5f0bee3b6 --- /dev/null +++ b/apps/sim/tools/lemlist/send_email.ts @@ -0,0 +1,106 @@ +import type { LemlistSendEmailParams, LemlistSendEmailResponse } from '@/tools/lemlist/types' +import type { ToolConfig } from '@/tools/types' + +export const sendEmailTool: ToolConfig = { + id: 'lemlist_send_email', + name: 'Lemlist Send Email', + description: 'Sends an email to a contact through the Lemlist inbox.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Lemlist API key', + }, + sendUserId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Identifier for the user sending the message', + }, + sendUserEmail: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the sender', + }, + sendUserMailboxId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Mailbox identifier for the sender', + }, + contactId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Recipient contact identifier', + }, + leadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Associated lead identifier', + }, + subject: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email subject line', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email message body in HTML format', + }, + cc: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Array of CC email addresses', + }, + }, + + request: { + url: () => 'https://api.lemlist.com/api/inbox/email', + method: 'POST', + headers: (params) => { + const credentials = Buffer.from(`:${params.apiKey}`).toString('base64') + return { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + sendUserId: params.sendUserId?.trim(), + sendUserEmail: params.sendUserEmail?.trim(), + sendUserMailboxId: params.sendUserMailboxId?.trim(), + contactId: params.contactId?.trim(), + leadId: params.leadId?.trim(), + subject: params.subject?.trim(), + message: params.message, + cc: params.cc ?? [], + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + ok: data.ok ?? true, + }, + } + }, + + outputs: { + ok: { + type: 'boolean', + description: 'Whether the email was sent successfully', + }, + }, +} diff --git a/apps/sim/tools/lemlist/types.ts b/apps/sim/tools/lemlist/types.ts new file mode 100644 index 0000000000..77c121df1c --- /dev/null +++ b/apps/sim/tools/lemlist/types.ts @@ -0,0 +1,75 @@ +import type { ToolResponse } from '@/tools/types' + +export interface LemlistBaseParams { + apiKey: string +} + +export interface LemlistGetActivitiesParams extends LemlistBaseParams { + type?: string + campaignId?: string + leadId?: string + isFirst?: boolean + limit?: number + offset?: number +} + +export interface LemlistActivity { + _id: string + type: string + leadId: string + campaignId: string + sequenceId: string | null + stepId: string | null + createdAt: string +} + +export interface LemlistGetActivitiesResponse extends ToolResponse { + output: { + activities: LemlistActivity[] + count: number + } +} + +export interface LemlistGetLeadParams extends LemlistBaseParams { + leadIdentifier: string +} + +export interface LemlistLead { + _id: string + email: string + firstName: string | null + lastName: string | null + companyName: string | null + jobTitle: string | null + companyDomain: string | null + isPaused: boolean + campaignId: string | null + contactId: string | null + emailStatus: string | null +} + +export interface LemlistGetLeadResponse extends ToolResponse { + output: LemlistLead +} + +export interface LemlistSendEmailParams extends LemlistBaseParams { + sendUserId: string + sendUserEmail: string + sendUserMailboxId: string + contactId: string + leadId: string + subject: string + message: string + cc?: string[] +} + +export interface LemlistSendEmailResponse extends ToolResponse { + output: { + ok: boolean + } +} + +export type LemlistResponse = + | LemlistGetActivitiesResponse + | LemlistGetLeadResponse + | LemlistSendEmailResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 296fab144f..05bcb9f5a3 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -643,6 +643,7 @@ import { knowledgeSearchTool, knowledgeUploadChunkTool, } from '@/tools/knowledge' +import { lemlistGetActivitiesTool, lemlistGetLeadTool, lemlistSendEmailTool } from '@/tools/lemlist' import { linearAddLabelToIssueTool, linearAddLabelToProjectTool, @@ -2416,6 +2417,9 @@ export const tools: Record = { linear_update_project_status: linearUpdateProjectStatusTool, linear_delete_project_status: linearDeleteProjectStatusTool, linear_list_project_statuses: linearListProjectStatusesTool, + lemlist_get_activities: lemlistGetActivitiesTool, + lemlist_get_lead: lemlistGetLeadTool, + lemlist_send_email: lemlistSendEmailTool, shopify_create_product: shopifyCreateProductTool, shopify_get_product: shopifyGetProductTool, shopify_list_products: shopifyListProductsTool, diff --git a/apps/sim/triggers/constants.ts b/apps/sim/triggers/constants.ts index d731246248..6c082b76f8 100644 --- a/apps/sim/triggers/constants.ts +++ b/apps/sim/triggers/constants.ts @@ -26,20 +26,13 @@ export const TRIGGER_PERSISTED_SUBBLOCK_IDS: string[] = [ 'selectedTriggerId', 'webhookId', 'triggerPath', - 'testUrl', - 'testUrlExpiresAt', ] /** * Trigger-related subblock IDs that represent runtime metadata. They should remain * in the workflow state but must not be modified or cleared by diff operations. */ -export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [ - 'webhookId', - 'triggerPath', - 'testUrl', - 'testUrlExpiresAt', -] +export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = ['webhookId', 'triggerPath'] /** * Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled. diff --git a/apps/sim/triggers/index.ts b/apps/sim/triggers/index.ts index 3bc5fe793e..d4c517e9c5 100644 --- a/apps/sim/triggers/index.ts +++ b/apps/sim/triggers/index.ts @@ -66,3 +66,115 @@ export function isTriggerValid(triggerId: string): boolean { } export type { TriggerConfig, TriggerRegistry } from '@/triggers/types' + +/** + * Options for building trigger subBlocks + */ +export interface BuildTriggerSubBlocksOptions { + /** The trigger ID (e.g., 'lemlist_email_replied') */ + triggerId: string + /** Dropdown options for selecting trigger type */ + triggerOptions: Array<{ label: string; id: string }> + /** Whether to include the trigger type dropdown (only for primary trigger) */ + includeDropdown?: boolean + /** HTML setup instructions to display */ + setupInstructions: string + /** Additional fields to insert before the save button (e.g., campaign filters) */ + extraFields?: SubBlockConfig[] + /** Webhook URL placeholder text */ + webhookPlaceholder?: string +} + +/** + * Generic builder for trigger subBlocks. + * Creates a consistent structure: [dropdown?] -> webhookUrl -> extraFields -> save -> instructions + * + * Usage: + * - Primary trigger: `buildTriggerSubBlocks({ ...options, includeDropdown: true })` + * - Secondary triggers: `buildTriggerSubBlocks({ ...options })` (no dropdown) + * + * @example + * ```typescript + * // Primary trigger (with dropdown) + * subBlocks: buildTriggerSubBlocks({ + * triggerId: 'service_event_a', + * triggerOptions: serviceTriggerOptions, + * includeDropdown: true, + * setupInstructions: serviceSetupInstructions('eventA'), + * }) + * + * // Secondary trigger (no dropdown) + * subBlocks: buildTriggerSubBlocks({ + * triggerId: 'service_event_b', + * triggerOptions: serviceTriggerOptions, + * setupInstructions: serviceSetupInstructions('eventB'), + * }) + * ``` + */ +export function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[] { + const { + triggerId, + triggerOptions, + includeDropdown = false, + setupInstructions, + extraFields = [], + webhookPlaceholder = 'Webhook URL will be generated', + } = options + + const blocks: SubBlockConfig[] = [] + + // Only the primary trigger includes the dropdown + if (includeDropdown) { + blocks.push({ + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: triggerOptions, + value: () => triggerId, + required: true, + }) + } + + // Webhook URL display (common to all triggers) + blocks.push({ + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: webhookPlaceholder, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + // Insert any extra fields (campaign filters, event types, etc.) + if (extraFields.length > 0) { + blocks.push(...extraFields) + } + + // Save button + blocks.push({ + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: triggerId, + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + // Setup instructions + blocks.push({ + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: setupInstructions, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }) + + return blocks +} diff --git a/apps/sim/triggers/lemlist/email_bounced.ts b/apps/sim/triggers/lemlist/email_bounced.ts new file mode 100644 index 0000000000..cd79184716 --- /dev/null +++ b/apps/sim/triggers/lemlist/email_bounced.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Email Bounced Trigger + * Triggers when an email bounces in a Lemlist campaign + */ +export const lemlistEmailBouncedTrigger: TriggerConfig = { + id: 'lemlist_email_bounced', + name: 'Lemlist Email Bounced', + provider: 'lemlist', + description: 'Trigger workflow when an email bounces', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_email_bounced', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('emailsBounced'), + extraFields: buildLemlistExtraFields('lemlist_email_bounced'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/email_clicked.ts b/apps/sim/triggers/lemlist/email_clicked.ts new file mode 100644 index 0000000000..a1da3ff6ba --- /dev/null +++ b/apps/sim/triggers/lemlist/email_clicked.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Email Clicked Trigger + * Triggers when a lead clicks a link in an email + */ +export const lemlistEmailClickedTrigger: TriggerConfig = { + id: 'lemlist_email_clicked', + name: 'Lemlist Email Clicked', + provider: 'lemlist', + description: 'Trigger workflow when a lead clicks a link in an email', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_email_clicked', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('emailsClicked'), + extraFields: buildLemlistExtraFields('lemlist_email_clicked'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/email_opened.ts b/apps/sim/triggers/lemlist/email_opened.ts new file mode 100644 index 0000000000..12c6638e81 --- /dev/null +++ b/apps/sim/triggers/lemlist/email_opened.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Email Opened Trigger + * Triggers when a lead opens an email in a Lemlist campaign + */ +export const lemlistEmailOpenedTrigger: TriggerConfig = { + id: 'lemlist_email_opened', + name: 'Lemlist Email Opened', + provider: 'lemlist', + description: 'Trigger workflow when a lead opens an email', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_email_opened', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('emailsOpened'), + extraFields: buildLemlistExtraFields('lemlist_email_opened'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/email_replied.ts b/apps/sim/triggers/lemlist/email_replied.ts new file mode 100644 index 0000000000..bb95476b3a --- /dev/null +++ b/apps/sim/triggers/lemlist/email_replied.ts @@ -0,0 +1,41 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailReplyOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Email Replied Trigger + * Triggers when a lead replies to an email in a Lemlist campaign + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + */ +export const lemlistEmailRepliedTrigger: TriggerConfig = { + id: 'lemlist_email_replied', + name: 'Lemlist Email Replied', + provider: 'lemlist', + description: 'Trigger workflow when a lead replies to an email', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_email_replied', + triggerOptions: lemlistTriggerOptions, + includeDropdown: true, + setupInstructions: lemlistSetupInstructions('emailsReplied'), + extraFields: buildLemlistExtraFields('lemlist_email_replied'), + }), + + outputs: buildEmailReplyOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/email_sent.ts b/apps/sim/triggers/lemlist/email_sent.ts new file mode 100644 index 0000000000..f45bdf4aaa --- /dev/null +++ b/apps/sim/triggers/lemlist/email_sent.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Email Sent Trigger + * Triggers when an email is sent in a Lemlist campaign + */ +export const lemlistEmailSentTrigger: TriggerConfig = { + id: 'lemlist_email_sent', + name: 'Lemlist Email Sent', + provider: 'lemlist', + description: 'Trigger workflow when an email is sent', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_email_sent', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('emailsSent'), + extraFields: buildLemlistExtraFields('lemlist_email_sent'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/index.ts b/apps/sim/triggers/lemlist/index.ts new file mode 100644 index 0000000000..8b0fce9561 --- /dev/null +++ b/apps/sim/triggers/lemlist/index.ts @@ -0,0 +1,14 @@ +/** + * Lemlist Triggers + * Export all Lemlist webhook triggers + */ + +export { lemlistEmailBouncedTrigger } from './email_bounced' +export { lemlistEmailClickedTrigger } from './email_clicked' +export { lemlistEmailOpenedTrigger } from './email_opened' +export { lemlistEmailRepliedTrigger } from './email_replied' +export { lemlistEmailSentTrigger } from './email_sent' +export { lemlistInterestedTrigger } from './interested' +export { lemlistLinkedInRepliedTrigger } from './linkedin_replied' +export { lemlistNotInterestedTrigger } from './not_interested' +export { lemlistWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/lemlist/interested.ts b/apps/sim/triggers/lemlist/interested.ts new file mode 100644 index 0000000000..e85ea40e9a --- /dev/null +++ b/apps/sim/triggers/lemlist/interested.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Interested Trigger + * Triggers when a lead is marked as interested in a Lemlist campaign + */ +export const lemlistInterestedTrigger: TriggerConfig = { + id: 'lemlist_interested', + name: 'Lemlist Lead Interested', + provider: 'lemlist', + description: 'Trigger workflow when a lead is marked as interested', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_interested', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('interested'), + extraFields: buildLemlistExtraFields('lemlist_interested'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/linkedin_replied.ts b/apps/sim/triggers/lemlist/linkedin_replied.ts new file mode 100644 index 0000000000..e3ccae016f --- /dev/null +++ b/apps/sim/triggers/lemlist/linkedin_replied.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildLemlistExtraFields, + buildLinkedInReplyOutputs, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist LinkedIn Replied Trigger + * Triggers when a lead replies to a LinkedIn message in a Lemlist campaign + */ +export const lemlistLinkedInRepliedTrigger: TriggerConfig = { + id: 'lemlist_linkedin_replied', + name: 'Lemlist LinkedIn Replied', + provider: 'lemlist', + description: 'Trigger workflow when a lead replies to a LinkedIn message', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_linkedin_replied', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('linkedinReplied'), + extraFields: buildLemlistExtraFields('lemlist_linkedin_replied'), + }), + + outputs: buildLinkedInReplyOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/not_interested.ts b/apps/sim/triggers/lemlist/not_interested.ts new file mode 100644 index 0000000000..5581429012 --- /dev/null +++ b/apps/sim/triggers/lemlist/not_interested.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildActivityOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Lemlist Not Interested Trigger + * Triggers when a lead is marked as not interested in a Lemlist campaign + */ +export const lemlistNotInterestedTrigger: TriggerConfig = { + id: 'lemlist_not_interested', + name: 'Lemlist Lead Not Interested', + provider: 'lemlist', + description: 'Trigger workflow when a lead is marked as not interested', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_not_interested', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('notInterested'), + extraFields: buildLemlistExtraFields('lemlist_not_interested'), + }), + + outputs: buildActivityOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/lemlist/utils.ts b/apps/sim/triggers/lemlist/utils.ts new file mode 100644 index 0000000000..69fbc1d4b3 --- /dev/null +++ b/apps/sim/triggers/lemlist/utils.ts @@ -0,0 +1,268 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Lemlist triggers + */ +export const lemlistTriggerOptions = [ + { label: 'Email Replied', id: 'lemlist_email_replied' }, + { label: 'LinkedIn Replied', id: 'lemlist_linkedin_replied' }, + { label: 'Lead Interested', id: 'lemlist_interested' }, + { label: 'Lead Not Interested', id: 'lemlist_not_interested' }, + { label: 'Email Opened', id: 'lemlist_email_opened' }, + { label: 'Email Clicked', id: 'lemlist_email_clicked' }, + { label: 'Email Bounced', id: 'lemlist_email_bounced' }, + { label: 'Email Sent', id: 'lemlist_email_sent' }, + { label: 'Generic Webhook (All Events)', id: 'lemlist_webhook' }, +] + +/** + * Generates setup instructions for Lemlist webhooks + * The webhook is automatically created in Lemlist when you save + */ +export function lemlistSetupInstructions(eventType: string): string { + const instructions = [ + 'Enter your Lemlist API Key above.', + 'You can find your API key in Lemlist at Settings > Integrations > API.', + `Click "Save Configuration" to automatically create the webhook in Lemlist for ${eventType} events.`, + 'The webhook will be automatically deleted when you remove this trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Helper to build Lemlist-specific extra fields. + * Includes API key (required) and optional campaign filter. + * Use with the generic buildTriggerSubBlocks from @/triggers. + */ +export function buildLemlistExtraFields(triggerId: string) { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as const, + placeholder: 'Enter your Lemlist API key', + description: 'Required to create the webhook in Lemlist.', + password: true, + required: true, + mode: 'trigger' as const, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'campaignId', + title: 'Campaign ID (Optional)', + type: 'short-input' as const, + placeholder: 'cam_xxxxx (leave empty for all campaigns)', + description: 'Optionally scope the webhook to a specific campaign', + mode: 'trigger' as const, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Base activity outputs shared across all Lemlist triggers + */ +function buildBaseActivityOutputs(): Record { + return { + type: { + type: 'string', + description: 'Activity type (emailsReplied, linkedinReplied, interested, emailsOpened, etc.)', + }, + _id: { + type: 'string', + description: 'Unique activity identifier', + }, + leadId: { + type: 'string', + description: 'Associated lead ID', + }, + campaignId: { + type: 'string', + description: 'Campaign ID', + }, + campaignName: { + type: 'string', + description: 'Campaign name', + }, + sequenceId: { + type: 'string', + description: 'Sequence ID within the campaign', + }, + stepId: { + type: 'string', + description: 'Step ID that triggered this activity', + }, + createdAt: { + type: 'string', + description: 'When the activity occurred (ISO 8601)', + }, + } +} + +/** + * Lead outputs - information about the lead + */ +function buildLeadOutputs(): Record { + return { + lead: { + _id: { + type: 'string', + description: 'Lead unique identifier', + }, + email: { + type: 'string', + description: 'Lead email address', + }, + firstName: { + type: 'string', + description: 'Lead first name', + }, + lastName: { + type: 'string', + description: 'Lead last name', + }, + companyName: { + type: 'string', + description: 'Lead company name', + }, + phone: { + type: 'string', + description: 'Lead phone number', + }, + linkedinUrl: { + type: 'string', + description: 'Lead LinkedIn profile URL', + }, + picture: { + type: 'string', + description: 'Lead profile picture URL', + }, + icebreaker: { + type: 'string', + description: 'Personalized icebreaker text', + }, + timezone: { + type: 'string', + description: 'Lead timezone (e.g., America/New_York)', + }, + isUnsubscribed: { + type: 'boolean', + description: 'Whether the lead is unsubscribed', + }, + }, + } +} + +/** + * Standard activity outputs (activity + lead data) + */ +export function buildActivityOutputs(): Record { + return { + ...buildBaseActivityOutputs(), + ...buildLeadOutputs(), + webhook: { + type: 'json', + description: 'Full webhook payload with all activity-specific data', + }, + } +} + +/** + * Email-specific outputs (includes message content for replies) + */ +export function buildEmailReplyOutputs(): Record { + return { + ...buildBaseActivityOutputs(), + ...buildLeadOutputs(), + messageId: { + type: 'string', + description: 'Email message ID', + }, + subject: { + type: 'string', + description: 'Email subject line', + }, + text: { + type: 'string', + description: 'Email reply text content', + }, + html: { + type: 'string', + description: 'Email reply HTML content', + }, + sentAt: { + type: 'string', + description: 'When the reply was sent', + }, + webhook: { + type: 'json', + description: 'Full webhook payload with all email data', + }, + } +} + +/** + * LinkedIn-specific outputs (includes message content) + */ +export function buildLinkedInReplyOutputs(): Record { + return { + ...buildBaseActivityOutputs(), + ...buildLeadOutputs(), + messageId: { + type: 'string', + description: 'LinkedIn message ID', + }, + text: { + type: 'string', + description: 'LinkedIn message text content', + }, + sentAt: { + type: 'string', + description: 'When the message was sent', + }, + webhook: { + type: 'json', + description: 'Full webhook payload with all LinkedIn data', + }, + } +} + +/** + * All outputs for generic webhook (activity + lead + all possible fields) + */ +export function buildAllOutputs(): Record { + return { + ...buildBaseActivityOutputs(), + ...buildLeadOutputs(), + messageId: { + type: 'string', + description: 'Message ID (for email/LinkedIn events)', + }, + subject: { + type: 'string', + description: 'Email subject (for email events)', + }, + text: { + type: 'string', + description: 'Message text content', + }, + html: { + type: 'string', + description: 'Message HTML content (for email events)', + }, + sentAt: { + type: 'string', + description: 'When the message was sent', + }, + webhook: { + type: 'json', + description: 'Full webhook payload with all data', + }, + } +} diff --git a/apps/sim/triggers/lemlist/webhook.ts b/apps/sim/triggers/lemlist/webhook.ts new file mode 100644 index 0000000000..289d8dead6 --- /dev/null +++ b/apps/sim/triggers/lemlist/webhook.ts @@ -0,0 +1,38 @@ +import { LemlistIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildAllOutputs, + buildLemlistExtraFields, + lemlistSetupInstructions, + lemlistTriggerOptions, +} from '@/triggers/lemlist/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Generic Lemlist Webhook Trigger + * Captures all Lemlist webhook events with optional filtering + */ +export const lemlistWebhookTrigger: TriggerConfig = { + id: 'lemlist_webhook', + name: 'Lemlist Webhook (All Events)', + provider: 'lemlist', + description: 'Trigger workflow on any Lemlist webhook event', + version: '1.0.0', + icon: LemlistIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'lemlist_webhook', + triggerOptions: lemlistTriggerOptions, + setupInstructions: lemlistSetupInstructions('All Events (no type filter)'), + extraFields: buildLemlistExtraFields('lemlist_webhook'), + }), + + outputs: buildAllOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 155f5cc424..1851d70d99 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -65,6 +65,17 @@ import { jiraWebhookTrigger, jiraWorklogCreatedTrigger, } from '@/triggers/jira' +import { + lemlistEmailBouncedTrigger, + lemlistEmailClickedTrigger, + lemlistEmailOpenedTrigger, + lemlistEmailRepliedTrigger, + lemlistEmailSentTrigger, + lemlistInterestedTrigger, + lemlistLinkedInRepliedTrigger, + lemlistNotInterestedTrigger, + lemlistWebhookTrigger, +} from '@/triggers/lemlist' import { linearCommentCreatedTrigger, linearCommentUpdatedTrigger, @@ -139,6 +150,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { jira_issue_deleted: jiraIssueDeletedTrigger, jira_issue_commented: jiraIssueCommentedTrigger, jira_worklog_created: jiraWorklogCreatedTrigger, + lemlist_webhook: lemlistWebhookTrigger, + lemlist_email_replied: lemlistEmailRepliedTrigger, + lemlist_email_opened: lemlistEmailOpenedTrigger, + lemlist_email_clicked: lemlistEmailClickedTrigger, + lemlist_email_sent: lemlistEmailSentTrigger, + lemlist_email_bounced: lemlistEmailBouncedTrigger, + lemlist_linkedin_replied: lemlistLinkedInRepliedTrigger, + lemlist_interested: lemlistInterestedTrigger, + lemlist_not_interested: lemlistNotInterestedTrigger, linear_webhook: linearWebhookTrigger, linear_issue_created: linearIssueCreatedTrigger, linear_issue_updated: linearIssueUpdatedTrigger,