diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index d252bf61666..d53e1bb609f 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -275,13 +275,15 @@ export const {Service}Block: BlockConfig = { 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. +All subscription lifecycle logic lives on the provider handler — **no code touches `route.ts` or `provider-subscriptions.ts`**. + ### 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. +Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, Ashby, Attio, etc. ### Implementation Steps @@ -337,188 +339,145 @@ export function {service}SetupInstructions(eventType: string): string { } ``` -#### 3. Add Webhook Creation to API Route +#### 3. Add `createSubscription` and `deleteSubscription` to the Provider Handler -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: +In `apps/sim/lib/webhooks/providers/{service}.ts`, add both lifecycle methods to your handler. The orchestration layer (`provider-subscriptions.ts`, `deploy.ts`, `route.ts`) calls these automatically — you never touch those files. ```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, +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:{Service}') + +export const {service}Handler: WebhookProviderHandler = { + // ... other methods (verifyAuth, formatInput, etc.) ... + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + + if (!apiKey) { + throw new Error('{Service} API Key is required.') } - 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 || {} + // Map trigger IDs to service event types + const eventTypeMap: Record = { + {service}_event_a: 'eventA', + {service}_event_b: 'eventB', + {service}_webhook: undefined, // Generic - no filter + } - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(ctx.webhook) - // 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 requestBody: Record = { + url: notificationUrl, + } + if (eventType) { + requestBody.eventType = eventType + } - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + const response = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) - const requestBody: Record = { - url: notificationUrl, - } + const responseBody = (await response.json()) as Record + + if (!response.ok) { + const errorMessage = (responseBody.message as string) || '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) + } - if (eventType) { - requestBody.eventType = eventType - } + const externalId = responseBody.id as string | undefined + if (!externalId) { + throw new Error('{Service} webhook created but no ID was returned.') + } - if (projectId) { - requestBody.projectId = projectId + logger.info(`[${ctx.requestId}] Created {Service} webhook ${externalId}`) + return { providerConfigUpdates: { externalId } } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${ctx.requestId}] {Service} webhook creation failed`, { + message: err.message, + }) + throw error } + }, - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined - const responseBody = await response.json() + if (!apiKey || !externalId) { + logger.warn(`[${ctx.requestId}] Missing apiKey or externalId, skipping cleanup`) + return + } - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' + const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }) - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` + if (!response.ok && response.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete {Service} webhook (non-fatal): ${response.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted {Service} webhook ${externalId}`) } - - throw new Error(userFriendlyMessage) + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting {Service} webhook (non-fatal)`, error) } - - 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 +#### How It Works -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +The orchestration layer handles everything automatically: -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 +1. **Creation**: `provider-subscriptions.ts` → `createExternalWebhookSubscription()` calls `handler.createSubscription()` → merges `providerConfigUpdates` into the saved webhook record. +2. **Deletion**: `provider-subscriptions.ts` → `cleanupExternalWebhook()` calls `handler.deleteSubscription()` → errors are caught and logged non-fatally. +3. **Polling config**: `deploy.ts` → `configurePollingIfNeeded()` calls `handler.configurePolling()` for credential-based providers (Gmail, Outlook, RSS, IMAP). - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } +You do NOT need to modify any orchestration files. Just implement the methods on your handler. - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) +#### Shared Utilities for Subscriptions - 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) - } -} -``` +Import from `@/lib/webhooks/providers/subscription-utils`: -3. Add to `cleanupExternalWebhook`: -```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` +- `getProviderConfig(webhook)` — safely extract `providerConfig` as `Record` +- `getNotificationUrl(webhook)` — build the full callback URL: `{baseUrl}/api/webhooks/trigger/{path}` +- `getCredentialOwner(credentialId, requestId)` — resolve OAuth credential to `{ userId, accountId }` (for OAuth-based providers like Airtable, Attio) ### 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 +- **Error handling**: Throw from `createSubscription` — the orchestration layer catches it, rolls back the DB webhook, and returns a 500 +- **External ID storage**: Return `{ providerConfigUpdates: { externalId } }` — the orchestration layer merges it into `providerConfig` +- **Graceful cleanup**: In `deleteSubscription`, catch errors and log non-fatally (never throw) +- **User-friendly errors**: Map HTTP status codes to helpful error messages in `createSubscription` ## The buildTriggerSubBlocks Helper @@ -552,6 +511,148 @@ All fields automatically have: - `mode: 'trigger'` - Only shown in trigger mode - `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +## Webhook Provider Handler (Optional) + +If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), **idempotency dedup**, **custom input formatting**, or **subscription lifecycle** — all of this lives in a single provider handler file. + +### Directory + +``` +apps/sim/lib/webhooks/providers/ +├── types.ts # WebhookProviderHandler interface (16 optional methods) +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl, getCredentialOwner) +├── registry.ts # Handler map + default handler +├── index.ts # Barrel export +└── {service}.ts # Your provider handler (ALL provider-specific logic here) +``` + +### When to Create a Handler + +| Behavior | Method to implement | Example providers | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event type filtering | `matchEvent` | GitHub, Jira, Confluence, Attio, HubSpot | +| Event skip by type list | `shouldSkipEvent` via `skipByEventTypes` | Stripe, Grain | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Microsoft Teams | +| Custom error format | `formatErrorResponse` | Microsoft Teams | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby, Gmail, Outlook | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Polling setup | `configurePolling` | Gmail, Outlook, RSS, IMAP | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Microsoft Teams | + +If none of these apply, you do NOT need a handler file. The default handler provides bearer token auth for providers that set `providerConfig.token`. + +### Simple Example: HMAC Auth Only + +Signature validators are defined as private functions **inside the handler file** (not in a shared utils file): + +```typescript +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + if (!signature.startsWith('sha256=')) return false + const provided = signature.substring(7) + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, provided) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), +} +``` + +### Example: Auth + Event Matching + Idempotency + +```typescript +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] {Service} event mismatch for trigger ${triggerId}. Skipping.`, + { webhookId: webhook.id, workflowId: workflow.id, triggerId } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.type) { + return `${obj.type}:${obj.id}` + } + return null + }, +} +``` + +### Registering the Handler + +In `apps/sim/lib/webhooks/providers/registry.ts`: + +```typescript +import { {service}Handler } from '@/lib/webhooks/providers/{service}' + +const PROVIDER_HANDLERS: Record = { + // ... existing providers (alphabetical) ... + {service}: {service}Handler, +} +``` + ## Trigger Outputs & Webhook Input Formatting ### Important: Two Sources of Truth @@ -559,35 +660,48 @@ All fields automatically have: There are two related but separate concerns: 1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: +**These MUST be aligned.** The fields returned by `formatInput` should match what's defined in trigger `outputs`. If they differ: - Tag dropdown shows fields that don't exist (broken variable resolution) - Or actual data has fields not shown in dropdown (users can't discover them) -### When to Add a formatWebhookInput Handler +### When to Add `formatInput` -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need it. The fallback passes through the raw body directly. +- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add `formatInput` to your handler. -### Adding a Handler +### Adding `formatInput` to Your Handler -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: +In `apps/sim/lib/webhooks/providers/{service}.ts`: ```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const {service}Handler: WebhookProviderHandler = { + // ... other methods ... + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + timestamp: b.created_at, + resource: b.data, + }, + } + }, } ``` **Key rules:** -- Return fields that match your trigger `outputs` definition exactly +- Return `{ input: { ... } }` where the inner object matches your trigger `outputs` definition exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution for this event - No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` - No duplication (don't spread body AND add individual fields) - Use `null` for missing optional data, not empty objects with empty strings @@ -688,21 +802,25 @@ export const {service}WebhookTrigger: TriggerConfig = { - [ ] Block has all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` +### Webhook Provider Handler (`providers/{service}.ts`) +- [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) +- [ ] Signature validator defined as private function inside handler file (not in a shared file) +- [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth +- [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth +- [ ] Event matching uses dynamic `await import()` for trigger utils +- [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`) + ### 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 - -### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +- [ ] Added `createSubscription` method to handler (uses `getNotificationUrl`, `getProviderConfig` from `subscription-utils`) +- [ ] Added `deleteSubscription` method to handler (catches errors, logs non-fatally) +- [ ] NO changes needed to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` ### Testing - [ ] Run `bun run type-check` to verify no TypeScript errors +- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify output alignment - [ ] 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/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index ea17b75cefe..c6ef9e992e1 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -16,13 +16,9 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - configureGmailPolling, - configureOutlookPolling, - configureRssPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' @@ -348,7 +344,6 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) - // --- Credential Set Handling --- // For credential sets, we fan out to create one webhook per credential at save time. // This applies to all OAuth-based triggers, not just polling ones. // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields @@ -402,16 +397,13 @@ export async function POST(request: NextRequest) { ) } - const needsConfiguration = provider === 'gmail' || provider === 'outlook' + const providerHandler = getProviderHandler(provider) - if (needsConfiguration) { - const configureFunc = - provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + if (providerHandler.configurePolling) { const configureErrors: string[] = [] for (const wh of syncResult.webhooks) { if (wh.isNew) { - // Fetch the webhook data for configuration const webhookRows = await db .select() .from(webhook) @@ -419,7 +411,10 @@ export async function POST(request: NextRequest) { .limit(1) if (webhookRows.length > 0) { - const success = await configureFunc(webhookRows[0], requestId) + const success = await providerHandler.configurePolling({ + webhook: webhookRows[0], + requestId, + }) if (!success) { configureErrors.push( `Failed to configure webhook for credential ${wh.credentialId}` @@ -436,7 +431,6 @@ export async function POST(request: NextRequest) { configureErrors.length > 0 && configureErrors.length === syncResult.webhooks.length ) { - // All configurations failed - roll back logger.error(`[${requestId}] All webhook configurations failed, rolling back`) for (const wh of syncResult.webhooks) { await db.delete(webhook).where(eq(webhook.id, wh.id)) @@ -488,8 +482,6 @@ export async function POST(request: NextRequest) { } } } - // --- End Credential Set Handling --- - let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -629,115 +621,49 @@ export async function POST(request: NextRequest) { } } - // --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) --- - if (savedWebhook && provider === 'gmail') { - logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) - try { - const success = await configureGmailPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure Gmail polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail polling', - details: 'Please check your Gmail account permissions and try again', - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Successfully configured Gmail polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Gmail webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } + if (savedWebhook) { + const pollingHandler = getProviderHandler(provider) + if (pollingHandler.configurePolling) { + logger.info( + `[${requestId}] ${provider} provider detected. Setting up polling configuration.` ) - } - } - // --- End Gmail specific logic --- + try { + const success = await pollingHandler.configurePolling({ + webhook: savedWebhook, + requestId, + }) - // --- Outlook webhook setup --- - if (savedWebhook && provider === 'outlook') { - logger.info( - `[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.` - ) - try { - const success = await configureOutlookPolling(savedWebhook, requestId) + if (!success) { + logger.error( + `[${requestId}] Failed to configure ${provider} polling, rolling back webhook` + ) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check your account permissions and try again', + }, + { status: 500 } + ) + } - if (!success) { - logger.error(`[${requestId}] Failed to configure Outlook polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook polling', - details: 'Please check your Outlook account permissions and try again', - }, - { status: 500 } + logger.info(`[${requestId}] Successfully configured ${provider} polling`) + } catch (err) { + logger.error( + `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`, + err ) - } - - logger.info(`[${requestId}] Successfully configured Outlook polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Outlook webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Outlook specific logic --- - - // --- RSS webhook setup --- - if (savedWebhook && provider === 'rss') { - logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`) - try { - const success = await configureRssPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { - error: 'Failed to configure RSS polling', - details: 'Please try again', + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', }, { status: 500 } ) } - - logger.info(`[${requestId}] Successfully configured RSS polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure RSS webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) } } - // --- End RSS specific logic --- if (!targetWebhookId && savedWebhook) { try { diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 7ee5d198f3f..88073b11cba 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -97,7 +97,6 @@ const { handleSlackChallengeMock, processWhatsAppDeduplicationMock, processGenericDeduplicationMock, - fetchAndProcessAirtablePayloadsMock, processWebhookMock, executeMock, getWorkspaceBilledAccountUserIdMock, @@ -109,7 +108,6 @@ const { handleSlackChallengeMock: vi.fn().mockReturnValue(null), processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null), processGenericDeduplicationMock: vi.fn().mockResolvedValue(null), - fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined), processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })), executeMock: vi.fn().mockResolvedValue({ success: true, @@ -156,10 +154,8 @@ vi.mock('@/background/logs-webhook-delivery', () => ({ vi.mock('@/lib/webhooks/utils', () => ({ handleWhatsAppVerification: handleWhatsAppVerificationMock, handleSlackChallenge: handleSlackChallengeMock, - verifyProviderWebhook: vi.fn().mockReturnValue(null), processWhatsAppDeduplication: processWhatsAppDeduplicationMock, processGenericDeduplication: processGenericDeduplicationMock, - fetchAndProcessAirtablePayloads: fetchAndProcessAirtablePayloadsMock, processWebhook: processWebhookMock, })) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 2c283b72fdb..a04c749af50 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -87,7 +87,7 @@ async function handleWebhookPost( if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( request.method, - body, + body as Record | undefined, requestId, path ) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index f9e01c6300c..7926ea0e382 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -7,12 +7,11 @@ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency' import { generateId } from '@/lib/core/utils/uuid' -import { processExecutionFiles } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' -import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -23,6 +22,7 @@ import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +import type { ExecutionResult } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' @@ -48,12 +48,12 @@ export function buildWebhookCorrelation( } /** - * Process trigger outputs based on their schema definitions - * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage + * Process trigger outputs based on their schema definitions. + * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage. */ async function processTriggerFileOutputs( - input: any, - triggerOutputs: Record, + input: unknown, + triggerOutputs: Record, context: { workspaceId: string workflowId: string @@ -62,29 +62,31 @@ async function processTriggerFileOutputs( userId?: string }, path = '' -): Promise { +): Promise { if (!input || typeof input !== 'object') { return input } - const processed: any = Array.isArray(input) ? [] : {} + const processed = (Array.isArray(input) ? [] : {}) as Record for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key - const outputDef = triggerOutputs[key] - const val: any = value + const outputDef = triggerOutputs[key] as Record | undefined + const val = value as Record - // If this field is marked as file or file[], process it if (outputDef?.type === 'file[]' && Array.isArray(val)) { try { - processed[key] = await WebhookAttachmentProcessor.processAttachments(val as any, context) + processed[key] = await WebhookAttachmentProcessor.processAttachments( + val as unknown as Parameters[0], + context + ) } catch (error) { processed[key] = [] } } else if (outputDef?.type === 'file' && val) { try { const [processedFile] = await WebhookAttachmentProcessor.processAttachments( - [val as any], + [val] as unknown as Parameters[0], context ) processed[key] = processedFile @@ -98,18 +100,20 @@ async function processTriggerFileOutputs( (outputDef.type === 'object' || outputDef.type === 'json') && outputDef.properties ) { - // Explicit object schema with properties - recurse into properties processed[key] = await processTriggerFileOutputs( val, - outputDef.properties, + outputDef.properties as Record, context, currentPath ) } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { - // Nested object in schema (flat pattern) - recurse with the nested schema - processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath) + processed[key] = await processTriggerFileOutputs( + val, + outputDef as Record, + context, + currentPath + ) } else { - // Not a file output - keep as is processed[key] = val } } @@ -125,7 +129,7 @@ export type WebhookExecutionPayload = { requestId?: string correlation?: AsyncExecutionCorrelation provider: string - body: any + body: unknown headers: Record path: string blockId?: string @@ -164,9 +168,6 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { ) } -/** - * Resolve the account userId for a credential - */ async function resolveCredentialAccountUserId(credentialId: string): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -180,6 +181,62 @@ async function resolveCredentialAccountUserId(credentialId: string): Promise + requestId: string + executionId: string + workflowId: string + } +) { + if ( + executionResult.status === 'cancelled' && + ctx.timeoutController.isTimedOut() && + ctx.timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, ctx.timeoutController.timeoutMs) + logger.info(`[${ctx.requestId}] Webhook execution timed out`, { + timeoutMs: ctx.timeoutController.timeoutMs, + }) + await ctx.loggingSession.markAsFailed(timeoutErrorMessage) + } else if (executionResult.status === 'paused') { + if (!executionResult.snapshotSeed) { + logger.error(`[${ctx.requestId}] Missing snapshot seed for paused execution`, { + executionId: ctx.executionId, + }) + await ctx.loggingSession.markAsFailed('Missing snapshot seed for paused execution') + } else { + try { + await PauseResumeManager.persistPauseResult({ + workflowId: ctx.workflowId, + executionId: ctx.executionId, + pausePoints: executionResult.pausePoints || [], + snapshotSeed: executionResult.snapshotSeed, + executorUserId: executionResult.metadata?.userId, + }) + } catch (pauseError) { + logger.error(`[${ctx.requestId}] Failed to persist pause result`, { + executionId: ctx.executionId, + error: pauseError instanceof Error ? pauseError.message : String(pauseError), + }) + await ctx.loggingSession.markAsFailed( + `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` + ) + } + } + } else { + await PauseResumeManager.processQueuedResumes(ctx.executionId) + } + + await ctx.loggingSession.waitForPostExecution() +} + async function executeWebhookJobInternal( payload: WebhookExecutionPayload, correlation: AsyncExecutionCorrelation @@ -192,7 +249,6 @@ async function executeWebhookJobInternal( requestId ) - // Resolve workflow record, billing actor, subscription, and timeout const preprocessResult = await preprocessExecution({ workflowId: payload.workflowId, userId: payload.userId, @@ -221,14 +277,13 @@ async function executeWebhookJobInternal( throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) } - const workflowVariables = (workflowRecord.variables as Record) || {} + const workflowVariables = (workflowRecord.variables as Record) || {} const asyncTimeout = executionTimeout?.async ?? 120_000 const timeoutController = createTimeoutAbortController(asyncTimeout) let deploymentVersionId: string | undefined try { - // Parallelize workflow state, webhook record, and credential resolution const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([ loadDeployedWorkflowState(payload.workflowId, workspaceId), db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1), @@ -255,184 +310,38 @@ async function executeWebhookJobInternal( ? (workflowData.deploymentVersionId as string) : undefined - // Handle special Airtable case - if (payload.provider === 'airtable') { - logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) - - const webhookRecord = webhookRows[0] - if (!webhookRecord) { - throw new Error(`Webhook record not found: ${payload.webhookId}`) - } - - const webhookData = { - id: payload.webhookId, - provider: payload.provider, - providerConfig: webhookRecord.providerConfig, - } - - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, - } - - const airtableInput = await fetchAndProcessAirtablePayloads( - webhookData, - mockWorkflow, - requestId - ) - - if (airtableInput) { - logger.info(`[${requestId}] Executing workflow with Airtable changes`) - - const metadata: ExecutionMetadata = { - requestId, - executionId, - workflowId: payload.workflowId, - workspaceId, - userId: payload.userId, - sessionUserId: undefined, - workflowUserId: workflowRecord.userId, - triggerType: payload.provider || 'webhook', - triggerBlockId: payload.blockId, - useDraftState: false, - startTime: new Date().toISOString(), - isClientSession: false, - credentialAccountUserId, - correlation, - workflowStateOverride: { - blocks, - edges, - loops: loops || {}, - parallels: parallels || {}, - deploymentVersionId, - }, - } - - const snapshot = new ExecutionSnapshot( - metadata, - workflowRecord, - airtableInput, - workflowVariables, - [] - ) - - const executionResult = await executeWorkflowCore({ - snapshot, - callbacks: {}, - loggingSession, - includeFileBase64: true, - base64MaxBytes: undefined, - abortSignal: timeoutController.signal, - }) - - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Airtable webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } - - await loggingSession.waitForPostExecution() - - logger.info(`[${requestId}] Airtable webhook execution completed`, { - success: executionResult.success, - workflowId: payload.workflowId, - }) + const handler = getProviderHandler(payload.provider) - return { - success: executionResult.success, - workflowId: payload.workflowId, - executionId, - output: executionResult.output, - executedAt: new Date().toISOString(), - provider: payload.provider, - } - } - // No changes to process - logger.info(`[${requestId}] No Airtable changes to process`) + let input: Record | null = null + let skipMessage: string | undefined - await loggingSession.safeStart({ - userId: payload.userId, - workspaceId, - variables: {}, - triggerData: { - isTest: false, - correlation, - }, - deploymentVersionId, - }) + const webhookRecord = webhookRows[0] + if (!webhookRecord) { + throw new Error(`Webhook record not found: ${payload.webhookId}`) + } - await loggingSession.safeComplete({ - endedAt: new Date().toISOString(), - totalDurationMs: 0, - finalOutput: { message: 'No Airtable changes to process' }, - traceSpans: [], + if (handler.formatInput) { + const result = await handler.formatInput({ + webhook: webhookRecord, + workflow: { id: payload.workflowId, userId: payload.userId }, + body: payload.body, + headers: payload.headers, + requestId, }) - - return { - success: true, - workflowId: payload.workflowId, - executionId, - output: { message: 'No Airtable changes to process' }, - executedAt: new Date().toISOString(), - } + input = result.input as Record | null + skipMessage = result.skip?.message + } else { + input = payload.body as Record | null } - // Format input for standard webhooks - const actualWebhook = - webhookRows.length > 0 - ? webhookRows[0] - : { - provider: payload.provider, - blockId: payload.blockId, - providerConfig: {}, - } - - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, + if (!input && handler.handleEmptyInput) { + const skipResult = handler.handleEmptyInput(requestId) + if (skipResult) { + skipMessage = skipResult.message + } } - const mockRequest = { - headers: new Map(Object.entries(payload.headers)), - } as any - - const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest) - - if (!input && payload.provider === 'whatsapp') { - logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) + if (skipMessage) { await loggingSession.safeStart({ userId: payload.userId, workspaceId, @@ -447,19 +356,19 @@ async function executeWebhookJobInternal( await loggingSession.safeComplete({ endedAt: new Date().toISOString(), totalDurationMs: 0, - finalOutput: { message: 'No messages in WhatsApp payload' }, + finalOutput: { message: skipMessage }, traceSpans: [], }) + return { success: true, workflowId: payload.workflowId, executionId, - output: { message: 'No messages in WhatsApp payload' }, + output: { message: skipMessage }, executedAt: new Date().toISOString(), } } - // Process trigger file outputs based on schema if (input && payload.blockId && blocks[payload.blockId]) { try { const triggerBlock = blocks[payload.blockId] @@ -502,49 +411,20 @@ async function executeWebhookJobInternal( } } - // Process generic webhook files based on inputFormat - if (input && payload.provider === 'generic' && payload.blockId && blocks[payload.blockId]) { + if (input && handler.processInputFiles && payload.blockId && blocks[payload.blockId]) { try { - const triggerBlock = blocks[payload.blockId] - - if (triggerBlock?.subBlocks?.inputFormat?.value) { - const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{ - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' - }> - - const fileFields = inputFormat.filter((field) => field.type === 'file[]') - - if (fileFields.length > 0 && typeof input === 'object' && input !== null) { - const executionContext = { - workspaceId, - workflowId: payload.workflowId, - executionId, - } - - for (const fileField of fileFields) { - const fieldValue = input[fileField.name] - - if (fieldValue && typeof fieldValue === 'object') { - const uploadedFiles = await processExecutionFiles( - fieldValue, - executionContext, - requestId, - payload.userId - ) - - if (uploadedFiles.length > 0) { - input[fileField.name] = uploadedFiles - logger.info( - `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` - ) - } - } - } - } - } + await handler.processInputFiles({ + input, + blocks, + blockId: payload.blockId, + workspaceId, + workflowId: payload.workflowId, + executionId, + requestId, + userId: payload.userId, + }) } catch (error) { - logger.error(`[${requestId}] Error processing generic webhook files:`, error) + logger.error(`[${requestId}] Error processing provider-specific files:`, error) } } @@ -589,49 +469,17 @@ async function executeWebhookJobInternal( callbacks: {}, loggingSession, includeFileBase64: true, + base64MaxBytes: undefined, abortSignal: timeoutController.signal, }) - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } - - await loggingSession.waitForPostExecution() + await handleExecutionResult(executionResult, { + loggingSession, + timeoutController, + requestId, + executionId, + workflowId: payload.workflowId, + }) logger.info(`[${requestId}] Webhook execution completed`, { success: executionResult.success, diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index d9adf4f504f..27d0746e2a9 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -6,7 +6,7 @@ import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getStorageMethod, type StorageMethod } from '@/lib/core/storage' import { generateId } from '@/lib/core/utils/uuid' -import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils' +import { extractProviderIdentifierFromBody } from '@/lib/webhooks/providers' const logger = createLogger('IdempotencyService') diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1dd2f8b8f99..43188e88f2b 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -11,11 +11,8 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' -import { - configureGmailPolling, - configureOutlookPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -233,29 +230,20 @@ function buildProviderConfig( async function configurePollingIfNeeded( provider: string, - savedWebhook: any, + savedWebhook: Record, requestId: string ): Promise { - if (provider === 'gmail') { - const success = await configureGmailPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: 'Failed to configure Gmail polling. Please check your Gmail account permissions.', - status: 500, - } - } + const handler = getProviderHandler(provider) + if (!handler.configurePolling) { + return null } - if (provider === 'outlook') { - const success = await configureOutlookPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: - 'Failed to configure Outlook polling. Please check your Outlook account permissions.', - status: 500, - } + const success = await handler.configurePolling({ webhook: savedWebhook, requestId }) + if (!success) { + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id as string)) + return { + message: `Failed to configure ${provider} polling. Please check your account permissions.`, + status: 500, } } @@ -297,7 +285,7 @@ async function syncCredentialSetWebhooks(params: { basePath: triggerPath, credentialSetId, oauthProviderId, - providerConfig: baseConfig as Record, + providerConfig: baseConfig as Record, requestId, deploymentVersionId, }) @@ -322,13 +310,13 @@ async function syncCredentialSetWebhooks(params: { } } - if (provider === 'gmail' || provider === 'outlook') { - const configureFunc = provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + const handler = getProviderHandler(provider) + if (handler.configurePolling) { for (const wh of syncResult.webhooks) { if (wh.isNew) { const rows = await db.select().from(webhook).where(eq(webhook.id, wh.id)).limit(1) if (rows.length > 0) { - const success = await configureFunc(rows[0], requestId) + const success = await handler.configurePolling({ webhook: rows[0], requestId }) if (!success) { await db.delete(webhook).where(eq(webhook.id, wh.id)) return { @@ -459,6 +447,18 @@ export async function saveTriggerWebhooksForDeploy({ } } + if (providerConfig.requireAuth && !providerConfig.token) { + await restorePreviousSubscriptions() + return { + success: false, + error: { + message: + 'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.', + status: 400, + }, + } + } + webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef }) if (providerConfig.credentialSetId) { @@ -558,13 +558,13 @@ export async function saveTriggerWebhooksForDeploy({ await restorePreviousSubscriptions() return { success: false, error: syncResult.error, warnings: collectedWarnings } } - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create webhook for ${block.id}`, error) await restorePreviousSubscriptions() return { success: false, error: { - message: error?.message || 'Failed to save trigger configuration', + message: (error as Error)?.message || 'Failed to save trigger configuration', status: 500, }, warnings: collectedWarnings, @@ -621,7 +621,7 @@ export async function saveTriggerWebhooksForDeploy({ updatedProviderConfig: result.updatedProviderConfig as Record, externalSubscriptionCreated: result.externalSubscriptionCreated, }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error) await pendingVerificationTracker.clearAll() for (const sub of createdSubscriptions) { @@ -649,7 +649,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to create external subscription', + message: (error as Error)?.message || 'Failed to create external subscription', status: 500, }, } @@ -722,7 +722,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: pollingError } } } - } catch (error: any) { + } catch (error: unknown) { await pendingVerificationTracker.clearAll() logger.error(`[${requestId}] Failed to insert webhook records`, error) for (const sub of createdSubscriptions) { @@ -750,7 +750,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to save webhook records', + message: (error as Error)?.message || 'Failed to save webhook records', status: 500, }, } diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index e3cb8dcde41..3f543780f71 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -106,17 +106,10 @@ vi.mock('@/lib/webhooks/utils', () => ({ vi.mock('@/lib/webhooks/utils.server', () => ({ handleSlackChallenge: vi.fn().mockReturnValue(null), handleWhatsAppVerification: vi.fn().mockResolvedValue(null), - validateAttioSignature: vi.fn().mockReturnValue(true), - validateCalcomSignature: vi.fn().mockReturnValue(true), - validateCirclebackSignature: vi.fn().mockReturnValue(true), - validateFirefliesSignature: vi.fn().mockReturnValue(true), - validateGitHubSignature: vi.fn().mockReturnValue(true), - validateJiraSignature: vi.fn().mockReturnValue(true), - validateLinearSignature: vi.fn().mockReturnValue(true), - validateMicrosoftTeamsSignature: vi.fn().mockReturnValue(true), - validateTwilioSignature: vi.fn().mockResolvedValue(true), - validateTypeformSignature: vi.fn().mockReturnValue(true), - verifyProviderWebhook: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: vi.fn().mockReturnValue({}), })) vi.mock('@/background/webhook-execution', () => ({ diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 34de5f54ba6..38f6cc81bbc 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -8,7 +8,6 @@ import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq' import { isProd } from '@/lib/core/config/feature-flags' -import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -18,30 +17,10 @@ import { matchesPendingWebhookVerificationProbe, requiresPendingWebhookVerification, } from '@/lib/webhooks/pending-verification' -import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' -import { - handleSlackChallenge, - handleWhatsAppVerification, - validateAshbySignature, - validateAttioSignature, - validateCalcomSignature, - validateCirclebackSignature, - validateFirefliesSignature, - validateGitHubSignature, - validateJiraSignature, - validateLinearSignature, - validateMicrosoftTeamsSignature, - validateTwilioSignature, - validateTypeformSignature, - verifyProviderWebhook, -} from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' -import { isConfluencePayloadMatch } from '@/triggers/confluence/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -import { isGitHubEventMatch } from '@/triggers/github/utils' -import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils' -import { isJiraEventMatch } from '@/triggers/jira/utils' const logger = createLogger('WebhookProcessor') @@ -61,19 +40,6 @@ export interface WebhookPreprocessingResult { correlation?: AsyncExecutionCorrelation } -function getExternalUrl(request: NextRequest): string { - const proto = request.headers.get('x-forwarded-proto') || 'https' - const host = request.headers.get('x-forwarded-host') || request.headers.get('host') - - if (host) { - const url = new URL(request.url) - const reconstructed = `${proto}://${host}${url.pathname}${url.search}` - return reconstructed - } - - return request.url -} - async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ valid: boolean error?: string @@ -106,13 +72,12 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ export async function parseWebhookBody( request: NextRequest, requestId: string -): Promise<{ body: any; rawBody: string } | NextResponse> { +): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { const requestClone = request.clone() rawBody = await requestClone.text() - // Allow empty body - some webhooks send empty payloads if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } @@ -123,7 +88,7 @@ export async function parseWebhookBody( return new NextResponse('Failed to read request body', { status: 400 }) } - let body: any + let body: unknown try { const contentType = request.headers.get('content-type') || '' @@ -139,10 +104,6 @@ export async function parseWebhookBody( } else { body = JSON.parse(rawBody) } - - // Allow empty JSON objects - some webhooks send empty payloads - if (Object.keys(body).length === 0) { - } } catch (parseError) { logger.error(`[${requestId}] Failed to parse webhook body`, { error: parseError instanceof Error ? parseError.message : String(parseError), @@ -155,38 +116,24 @@ export async function parseWebhookBody( return { body, rawBody } } +/** Providers that implement challenge/verification handling, checked before webhook lookup. */ +const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const + export async function handleProviderChallenges( - body: any, + body: unknown, request: NextRequest, requestId: string, path: string ): Promise { - const slackResponse = handleSlackChallenge(body) - if (slackResponse) { - return slackResponse - } - - const url = new URL(request.url) - - // Microsoft Graph subscription validation (can come as GET or POST) - const validationToken = url.searchParams.get('validationToken') - if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) - return new NextResponse(validationToken, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }) - } - - const mode = url.searchParams.get('hub.mode') - const token = url.searchParams.get('hub.verify_token') - const challenge = url.searchParams.get('hub.challenge') - - const whatsAppResponse = await handleWhatsAppVerification(requestId, path, mode, token, challenge) - if (whatsAppResponse) { - return whatsAppResponse + for (const provider of CHALLENGE_PROVIDERS) { + const handler = getProviderHandler(provider) + if (handler.handleChallenge) { + const response = await handler.handleChallenge(body, request, requestId, path) + if (response) { + return response + } + } } - return null } @@ -218,108 +165,54 @@ export async function handlePreLookupWebhookVerification( /** * Handle provider-specific reachability tests that occur AFTER webhook lookup. - * - * @param webhook - The webhook record from the database - * @param body - The parsed request body - * @param requestId - Request ID for logging - * @returns NextResponse if this is a verification request, null to continue normal flow + * Delegates to the provider handler registry. */ export function handleProviderReachabilityTest( - webhook: any, - body: any, + webhookRecord: { provider: string }, + body: unknown, requestId: string ): NextResponse | null { - const provider = webhook?.provider - - if (provider === 'grain') { - const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type - if (isVerificationRequest) { - logger.info( - `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` - ) - return NextResponse.json({ - status: 'ok', - message: 'Webhook endpoint verified', - }) - } - } - - return null + const handler = getProviderHandler(webhookRecord?.provider) + return handler.handleReachabilityTest?.(body, requestId) ?? null } /** * Format error response based on provider requirements. - * Some providers (like Microsoft Teams) require specific response formats. + * Delegates to the provider handler registry. */ export function formatProviderErrorResponse( - webhook: any, + webhookRecord: { provider: string }, error: string, status: number ): NextResponse { - if (webhook.provider === 'microsoft-teams') { - return NextResponse.json({ type: 'message', text: error }, { status }) - } - return NextResponse.json({ error }, { status }) + const handler = getProviderHandler(webhookRecord.provider) + return handler.formatErrorResponse?.(error, status) ?? NextResponse.json({ error }, { status }) } /** * Check if a webhook event should be skipped based on provider-specific filtering. - * Returns true if the event should be skipped, false if it should be processed. + * Delegates to the provider handler registry. */ -export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean { - const providerConfig = (webhook.providerConfig as Record) || {} - - if (webhook.provider === 'stripe') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - if (webhook.provider === 'grain') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Grain event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - // Webflow collection filtering - filter by collectionId if configured - if (webhook.provider === 'webflow') { - const configuredCollectionId = providerConfig.collectionId - if (configuredCollectionId) { - const payloadCollectionId = body?.payload?.collectionId || body?.collectionId - if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { - logger.info( - `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - return false +export function shouldSkipWebhookEvent( + webhookRecord: { provider: string; providerConfig?: Record }, + body: unknown, + requestId: string +): boolean { + const handler = getProviderHandler(webhookRecord.provider) + const providerConfig = webhookRecord.providerConfig ?? {} + return ( + handler.shouldSkipEvent?.({ webhook: webhookRecord, body, requestId, providerConfig }) ?? false + ) } /** Returns 200 OK for providers that validate URLs before the workflow is deployed */ export function handlePreDeploymentVerification( - webhook: any, + webhookRecord: { provider: string }, requestId: string ): NextResponse | null { - if (requiresPendingWebhookVerification(webhook.provider)) { + if (requiresPendingWebhookVerification(webhookRecord.provider)) { logger.info( - `[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation` + `[${requestId}] ${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation` ) return NextResponse.json({ status: 'ok', @@ -458,27 +351,15 @@ export async function findAllWebhooksForPath( return results } -/** - * Resolve {{VARIABLE}} references in a string value - * @param value - String that may contain {{VARIABLE}} references - * @param envVars - Already decrypted environment variables - * @returns String with all {{VARIABLE}} references replaced - */ function resolveEnvVars(value: string, envVars: Record): string { return resolveEnvVarReferences(value, envVars) as string } -/** - * Resolve environment variables in webhook providerConfig - * @param config - Raw providerConfig from database (may contain {{VARIABLE}} refs) - * @param envVars - Already decrypted environment variables - * @returns New object with resolved values (original config is unchanged) - */ function resolveProviderConfigEnvVars( - config: Record, + config: Record, envVars: Record -): Record { - const resolved: Record = {} +): Record { + const resolved: Record = {} for (const [key, value] of Object.entries(config)) { if (typeof value === 'string') { resolved[key] = resolveEnvVars(value, envVars) @@ -490,8 +371,8 @@ function resolveProviderConfigEnvVars( } /** - * Verify webhook provider authentication and signatures - * @returns NextResponse with 401 if auth fails, null if auth passes + * Verify webhook provider authentication and signatures. + * Delegates to the provider handler registry. */ export async function verifyProviderAuth( foundWebhook: any, @@ -500,7 +381,6 @@ export async function verifyProviderAuth( rawBody: string, requestId: string ): Promise { - // Step 1: Fetch and decrypt environment variables for signature verification let decryptedEnvVars: Record = {} try { decryptedEnvVars = await getEffectiveDecryptedEnv( @@ -513,429 +393,20 @@ export async function verifyProviderAuth( }) } - // Step 2: Resolve {{VARIABLE}} references in providerConfig - const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} + const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars) - if (foundWebhook.provider === 'microsoft-teams') { - if (providerConfig.hmacSecret) { - const authHeader = request.headers.get('authorization') - - if (!authHeader || !authHeader.startsWith('HMAC ')) { - logger.warn( - `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` - ) - return new NextResponse('Unauthorized - Missing HMAC signature', { - status: 401, - }) - } - - const isValidSignature = validateMicrosoftTeamsSignature( - providerConfig.hmacSecret, - authHeader, - rawBody - ) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) - return new NextResponse('Unauthorized - Invalid HMAC signature', { - status: 401, - }) - } - } - } - - // Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header) - if (foundWebhook.provider === 'ashby') { - const secretToken = providerConfig.secretToken as string | undefined - - if (secretToken) { - const signature = request.headers.get('ashby-signature') - - if (!signature) { - logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`) - return new NextResponse('Unauthorized - Missing Ashby signature', { - status: 401, - }) - } - - if (!validateAshbySignature(secretToken, signature, rawBody)) { - logger.warn(`[${requestId}] Ashby webhook signature verification failed`) - return new NextResponse('Unauthorized - Invalid Ashby signature', { - status: 401, - }) - } - } - } - - // Provider-specific verification (utils may return a response for some providers) - const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId) - if (providerVerification) { - return providerVerification - } - - // Handle Google Forms shared-secret authentication (Apps Script forwarder) - if (foundWebhook.provider === 'google_forms') { - const expectedToken = providerConfig.token as string | undefined - const secretHeaderName = providerConfig.secretHeaderName as string | undefined - - if (expectedToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue === expectedToken) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (token === expectedToken) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - logger.warn(`[${requestId}] Google Forms webhook authentication failed`) - return new NextResponse('Unauthorized - Invalid secret', { - status: 401, - }) - } - } - } - - // Twilio Voice webhook signature verification - if (foundWebhook.provider === 'twilio_voice') { - const authToken = providerConfig.authToken as string | undefined - - if (authToken) { - const signature = request.headers.get('x-twilio-signature') - - if (!signature) { - logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Twilio signature', { - status: 401, - }) - } - - let params: Record = {} - try { - if (typeof rawBody === 'string') { - const urlParams = new URLSearchParams(rawBody) - params = Object.fromEntries(urlParams.entries()) - } - } catch (error) { - logger.error( - `[${requestId}] Error parsing Twilio webhook body for signature validation:`, - error - ) - return new NextResponse('Bad Request - Invalid body format', { - status: 400, - }) - } - - const fullUrl = getExternalUrl(request) - const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { - url: fullUrl, - signatureLength: signature.length, - paramsCount: Object.keys(params).length, - authTokenLength: authToken.length, - }) - return new NextResponse('Unauthorized - Invalid Twilio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'typeform') { - const secret = providerConfig.secret as string | undefined - - if (secret) { - const signature = request.headers.get('Typeform-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Typeform webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Typeform signature', { - status: 401, - }) - } - - const isValidSignature = validateTypeformSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Typeform signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Typeform signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'attio') { - const secret = providerConfig.webhookSecret as string | undefined - - if (!secret) { - logger.debug( - `[${requestId}] Attio webhook ${foundWebhook.id} has no signing secret, skipping signature verification` - ) - } else { - const signature = request.headers.get('Attio-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Attio webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Attio signature', { - status: 401, - }) - } - - const isValidSignature = validateAttioSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Attio signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Attio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'linear') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('Linear-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Linear webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Linear signature', { - status: 401, - }) - } - - const isValidSignature = validateLinearSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Linear signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Linear signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'circleback') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-signature') - - if (!signature) { - logger.warn(`[${requestId}] Circleback webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Circleback signature', { - status: 401, - }) - } - - const isValidSignature = validateCirclebackSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Circleback signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Circleback signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'calcom') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Cal-Signature-256') - - if (!signature) { - logger.warn(`[${requestId}] Cal.com webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Cal.com signature', { - status: 401, - }) - } - - const isValidSignature = validateCalcomSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Cal.com signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Cal.com signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'jira') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Jira webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Jira signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Jira signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Jira signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'confluence') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Confluence webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Confluence signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Confluence signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Confluence signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'github') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - // GitHub supports both SHA-256 (preferred) and SHA-1 (legacy) - const signature256 = request.headers.get('X-Hub-Signature-256') - const signature1 = request.headers.get('X-Hub-Signature') - const signature = signature256 || signature1 - - if (!signature) { - logger.warn(`[${requestId}] GitHub webhook missing signature header`) - return new NextResponse('Unauthorized - Missing GitHub signature', { - status: 401, - }) - } - - const isValidSignature = validateGitHubSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] GitHub signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - usingSha256: !!signature256, - }) - return new NextResponse('Unauthorized - Invalid GitHub signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'fireflies') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-hub-signature') - - if (!signature) { - logger.warn(`[${requestId}] Fireflies webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Fireflies signature', { - status: 401, - }) - } - - const isValidSignature = validateFirefliesSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Fireflies signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Fireflies signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'generic') { - if (providerConfig.requireAuth) { - const configToken = providerConfig.token - const secretHeaderName = providerConfig.secretHeaderName - - if (configToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue && safeCompare(headerValue, configToken)) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (safeCompare(token, configToken)) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } else { - return new NextResponse('Unauthorized - Authentication required but not configured', { - status: 401, - }) - } - } + const handler = getProviderHandler(foundWebhook.provider) + if (handler.verifyAuth) { + const authResult = await handler.verifyAuth({ + webhook: foundWebhook, + workflow: foundWorkflow, + request, + rawBody, + requestId, + providerConfig, + }) + if (authResult) return authResult } return null @@ -943,7 +414,6 @@ export async function verifyProviderAuth( /** * Run preprocessing checks for webhook execution - * This replaces the old checkRateLimits and checkUsageLimits functions */ export async function checkWebhookPreprocessing( foundWorkflow: any, @@ -984,20 +454,8 @@ export async function checkWebhookPreprocessing( statusCode: error.statusCode, }) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: error.message, - }, - { status: error.statusCode } - ), - } - } - return { - error: NextResponse.json({ error: error.message }, { status: error.statusCode }), + error: formatProviderErrorResponse(foundWebhook, error.message, error.statusCode), } } @@ -1010,20 +468,8 @@ export async function checkWebhookPreprocessing( } catch (preprocessError) { logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: 'Internal error during preprocessing', - }, - { status: 500 } - ), - } - } - return { - error: NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 }), + error: formatProviderErrorResponse(foundWebhook, 'Internal error during preprocessing', 500), } } } @@ -1035,188 +481,41 @@ export async function queueWebhookExecution( request: NextRequest, options: WebhookProcessorOptions ): Promise { - try { - // GitHub event filtering for event-specific triggers - if (foundWebhook.provider === 'github') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'github_webhook') { - const eventType = request.headers.get('x-github-event') - const action = body.action - - if (!isGitHubEventMatch(triggerId, eventType || '', action, body)) { - logger.debug( - `[${options.requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - receivedAction: action, - } - ) - - // Return 200 OK to prevent GitHub from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - } - } - - // Jira event filtering for event-specific triggers - if (foundWebhook.provider === 'jira') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'jira_webhook') { - const webhookEvent = body.webhookEvent as string | undefined + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const handler = getProviderHandler(foundWebhook.provider) - if (!isJiraEventMatch(triggerId, webhookEvent || '', body)) { - logger.debug( - `[${options.requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: webhookEvent, - } - ) - - // Return 200 OK to prevent Jira from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) + try { + if (handler.matchEvent) { + const result = await handler.matchEvent({ + webhook: foundWebhook, + workflow: foundWorkflow, + body, + request, + requestId: options.requestId, + providerConfig, + }) + if (result !== true) { + if (result instanceof NextResponse) { + return result } - } - } - - if (foundWebhook.provider === 'confluence') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && !isConfluencePayloadMatch(triggerId, body)) { - logger.debug( - `[${options.requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - message: 'Payload does not match trigger configuration. Ignoring.', + message: 'Event type does not match trigger configuration. Ignoring.', }) } } - if (foundWebhook.provider === 'attio') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'attio_webhook') { - const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') - if (!isAttioPayloadMatch(triggerId, body)) { - const event = getAttioEvent(body) - const eventType = event?.event_type as string | undefined - logger.debug( - `[${options.requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - status: 'skipped', - reason: 'event_type_mismatch', - }) - } - } - } - - if (foundWebhook.provider === 'hubspot') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('hubspot_')) { - const events = Array.isArray(body) ? body : [body] - const firstEvent = events[0] - - const subscriptionType = firstEvent?.subscriptionType as string | undefined - - if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { - logger.debug( - `[${options.requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - - // Return 200 OK to prevent HubSpot from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - - logger.info( - `[${options.requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - } - } - const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries()) - // For Microsoft Teams Graph notifications, extract unique identifiers for idempotency - if ( - foundWebhook.provider === 'microsoft-teams' && - body?.value && - Array.isArray(body.value) && - body.value.length > 0 - ) { - const notification = body.value[0] - const subscriptionId = notification.subscriptionId - const messageId = notification.resourceData?.id - - if (subscriptionId && messageId) { - headers['x-teams-notification-id'] = `${subscriptionId}:${messageId}` - } - } - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - if (foundWebhook.provider === 'generic') { - const idempotencyField = providerConfig.idempotencyField as string | undefined - if (idempotencyField && body) { - const value = idempotencyField - .split('.') - .reduce((acc: any, key: string) => acc?.[key], body) - if (value !== undefined && value !== null && typeof value !== 'object') { - headers['x-sim-idempotency-key'] = String(value) - } - } + if (handler.enrichHeaders) { + handler.enrichHeaders( + { webhook: foundWebhook, body, requestId: options.requestId, providerConfig }, + headers + ) } const credentialId = providerConfig.credentialId as string | undefined - - // credentialSetId is a direct field on webhook table, not in providerConfig const credentialSetId = foundWebhook.credentialSetId as string | undefined - // Verify billing for credential sets if (credentialSetId) { const billingCheck = await verifyCredentialSetBilling(credentialSetId) if (!billingCheck.valid) { @@ -1355,112 +654,18 @@ export async function queueWebhookExecution( } } - if (foundWebhook.provider === 'microsoft-teams') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Chat subscription (Graph API) returns 202 - if (triggerId === 'microsoftteams_chat_subscription') { - return new NextResponse(null, { status: 202 }) - } - - // Channel webhook (outgoing webhook) returns message response - return NextResponse.json({ - type: 'message', - text: 'Sim', - }) - } - - // Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.) - // A JSON body like {"message":"..."} is not a recognized response format and causes modal errors - if (foundWebhook.provider === 'slack') { - return new NextResponse(null, { status: 200 }) - } - - // Twilio Voice requires TwiML XML response - if (foundWebhook.provider === 'twilio_voice') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() - - // If user provided custom TwiML, convert square brackets to angle brackets and return - if (twimlResponse && twimlResponse.length > 0) { - const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) - return new NextResponse(convertedTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - // Default TwiML if none provided - const defaultTwiml = ` - - Your call is being processed. - -` - - return new NextResponse(defaultTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') { - const rawCode = Number(providerConfig.responseStatusCode) || 200 - const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 - const responseBody = (providerConfig.responseBody as string | undefined)?.trim() - - if (!responseBody) { - return new NextResponse(null, { status: statusCode }) - } - - try { - const parsed = JSON.parse(responseBody) - return NextResponse.json(parsed, { status: statusCode }) - } catch { - return new NextResponse(responseBody, { - status: statusCode, - headers: { 'Content-Type': 'text/plain' }, - }) - } + const successResponse = handler.formatSuccessResponse?.(providerConfig) ?? null + if (successResponse) { + return successResponse } return NextResponse.json({ message: 'Webhook processed' }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) - if (foundWebhook.provider === 'microsoft-teams') { - return NextResponse.json( - { - type: 'message', - text: 'Webhook processing failed', - }, - { status: 500 } - ) - } - - if (foundWebhook.provider === 'slack') { - // Return empty 200 to avoid Slack showing an error dialog to the user, - // even though processing failed. The error is already logged above. - return new NextResponse(null, { status: 200 }) - } - - if (foundWebhook.provider === 'twilio_voice') { - const errorTwiml = ` - - We're sorry, but an error occurred processing your call. Please try again later. - -` - - return new NextResponse(errorTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml', - }, - }) + const errorResponse = handler.formatQueueErrorResponse?.() ?? null + if (errorResponse) { + return errorResponse } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 4c5e16ffc1a..227e05753ab 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -1,1978 +1,8 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { generateId } from '@/lib/core/utils/uuid' -import { - getOAuthToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getProviderHandler } from '@/lib/webhooks/providers' -const teamsLogger = createLogger('TeamsSubscription') -const telegramLogger = createLogger('TelegramWebhook') -const airtableLogger = createLogger('AirtableWebhook') -const typeformLogger = createLogger('TypeformWebhook') -const calendlyLogger = createLogger('CalendlyWebhook') -const ashbyLogger = createLogger('AshbyWebhook') -const grainLogger = createLogger('GrainWebhook') -const fathomLogger = createLogger('FathomWebhook') -const lemlistLogger = createLogger('LemlistWebhook') -const webflowLogger = createLogger('WebflowWebhook') -const attioLogger = createLogger('AttioWebhook') -const providerSubscriptionsLogger = createLogger('WebhookProviderSubscriptions') - -function getProviderConfig(webhook: any): Record { - return (webhook.providerConfig as Record) || {} -} - -function getNotificationUrl(webhook: any): string { - return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` -} - -async function getCredentialOwner( - credentialId: string, - requestId: string -): Promise<{ userId: string; accountId: string } | null> { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - providerSubscriptionsLogger.warn( - `[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}` - ) - return null - } - const [credentialRecord] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentialRecord?.userId) { - providerSubscriptionsLogger.warn( - `[${requestId}] Credential owner not found for credentialId ${credentialId}` - ) - return null - } - - return { userId: credentialRecord.userId, accountId: resolved.accountId } -} - -/** - * Create a Microsoft Teams chat subscription - * Throws errors with friendly messages if subscription creation fails - */ -export async function createTeamsSubscription( - request: NextRequest, - webhook: any, - workflow: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return undefined - } - - const credentialId = config.credentialId as string | undefined - const chatId = config.chatId as string | undefined - - if (!credentialId) { - teamsLogger.warn( - `[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}` - ) - throw new Error( - 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' - ) - } - - if (!chatId) { - teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) - throw new Error( - 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) - : null - if (!accessToken) { - teamsLogger.error( - `[${requestId}] Failed to get access token for Teams subscription ${webhook.id}` - ) - throw new Error( - 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' - ) - } - - const existingSubscriptionId = config.externalSubscriptionId as string | undefined - if (existingSubscriptionId) { - try { - const checkRes = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, - { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (checkRes.ok) { - teamsLogger.info( - `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` - ) - return existingSubscriptionId - } - } catch { - teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`) - } - } - - const notificationUrl = getNotificationUrl(webhook) - const resource = `/chats/${chatId}/messages` - - // Max lifetime: 4230 minutes (~3 days) - Microsoft Graph API limit - const maxLifetimeMinutes = 4230 - const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() - - const body = { - changeType: 'created,updated', - notificationUrl, - lifecycleNotificationUrl: notificationUrl, - resource, - includeResourceData: false, - expirationDateTime, - clientState: webhook.id, - } - - try { - const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) - - const payload = await res.json() - if (!res.ok) { - const errorMessage = - payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' - teamsLogger.error( - `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, - { - status: res.status, - error: payload.error, - } - ) - - let userFriendlyMessage = 'Failed to create Teams subscription' - if (res.status === 401 || res.status === 403) { - userFriendlyMessage = - 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' - } else if (res.status === 404) { - userFriendlyMessage = - 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' - } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { - userFriendlyMessage = `Teams error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - teamsLogger.info( - `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` - ) - return payload.id as string - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('credentials') || - error.message.includes('Chat ID') || - error.message.includes('authenticate')) - ) { - throw error - } - - teamsLogger.error( - `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Teams subscription. Please try again.' - ) - } -} - -/** - * Delete a Microsoft Teams chat subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTeamsSubscription( - webhook: any, - workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return - } - - const externalSubscriptionId = config.externalSubscriptionId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalSubscriptionId || !credentialId) { - teamsLogger.info( - `[${requestId}] No external subscription to delete for webhook ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - teamsLogger.warn( - `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` - ) - return - } - - const res = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ) - - if (res.ok || res.status === 404) { - teamsLogger.info( - `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` - ) - } else { - const errorBody = await res.text() - teamsLogger.warn( - `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` - ) - } - } catch (error) { - teamsLogger.error( - `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Create a Telegram bot webhook - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTelegramWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`) - throw new Error( - 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' - ) - } - - const notificationUrl = getNotificationUrl(webhook) - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` - - try { - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', - }, - body: JSON.stringify({ url: notificationUrl }), - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to create Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - - let userFriendlyMessage = 'Failed to create Telegram webhook' - if (telegramResponse.status === 401) { - userFriendlyMessage = - 'Invalid bot token. Please verify that the bot token is correct and try again.' - } else if (responseBody.description) { - userFriendlyMessage = `Telegram error: ${responseBody.description}` - } - - throw new Error(userFriendlyMessage) - } - - telegramLogger.info( - `[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}` - ) - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Bot token') || error.message.includes('Telegram error')) - ) { - throw error - } - - telegramLogger.error( - `[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Telegram webhook. Please try again.' - ) - } -} - -/** - * Delete a Telegram bot webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn( - `[${requestId}] Missing botToken for Telegram webhook deletion ${webhook.id}` - ) - return - } - - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - } else { - telegramLogger.info( - `[${requestId}] Successfully deleted Telegram webhook for webhook ${webhook.id}` - ) - } - } catch (error) { - telegramLogger.error( - `[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Delete an Airtable webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteAirtableWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const { baseId, externalId } = config as { - baseId?: string - externalId?: string - } - - if (!baseId) { - airtableLogger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { - webhookId: webhook.id, - }) - return - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { - webhookId: webhook.id, - baseId: baseId.substring(0, 20), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - airtableLogger.warn( - `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, - { webhookId: webhook.id } - ) - return - } - - let resolvedExternalId: string | undefined = externalId - - if (!resolvedExternalId) { - try { - const expectedNotificationUrl = getNotificationUrl(webhook) - - const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - const listResp = await fetch(listUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - const listBody = await listResp.json().catch(() => null) - - if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { - const match = listBody.webhooks.find((w: any) => { - const url: string | undefined = w?.notificationUrl - if (!url) return false - return ( - url === expectedNotificationUrl || - url.endsWith(`/api/webhooks/trigger/${webhook.path}`) - ) - }) - if (match?.id) { - resolvedExternalId = match.id as string - airtableLogger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { - baseId, - externalId: resolvedExternalId, - }) - } else { - airtableLogger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { - baseId, - expectedNotificationUrl, - }) - } - } else { - airtableLogger.warn( - `[${requestId}] Failed to list Airtable webhooks to resolve externalId`, - { - baseId, - status: listResp.status, - body: listBody, - } - ) - } - } catch (e: any) { - airtableLogger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { - error: e?.message, - }) - } - } - - if (!resolvedExternalId) { - airtableLogger.info( - `[${requestId}] Airtable externalId not found; skipping remote deletion`, - { baseId } - ) - return - } - - const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') - if (!webhookIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: resolvedExternalId.substring(0, 20), - }) - return - } - - const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` - const airtableResponse = await fetch(airtableDeleteUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!airtableResponse.ok) { - let responseBody: any = null - try { - responseBody = await airtableResponse.json() - } catch { - // Ignore parse errors - } - - airtableLogger.warn( - `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, - { baseId, externalId: resolvedExternalId, response: responseBody } - ) - } else { - airtableLogger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { - baseId, - externalId: resolvedExternalId, - }) - } - } catch (error: any) { - airtableLogger.error(`[${requestId}] Error deleting Airtable webhook`, { - webhookId: webhook.id, - error: error.message, - stack: error.stack, - }) - } -} - -/** - * Create a Typeform webhook subscription - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTypeformWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - const secret = config.secret as string | undefined - - if (!formId) { - typeformLogger.warn(`[${requestId}] Missing formId for Typeform webhook ${webhook.id}`) - throw new Error( - 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' - ) - } - - if (!apiKey) { - typeformLogger.warn(`[${requestId}] Missing apiKey for Typeform webhook ${webhook.id}`) - throw new Error( - 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' - ) - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const notificationUrl = getNotificationUrl(webhook) - - try { - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const requestBody: Record = { - url: notificationUrl, - enabled: true, - verify_ssl: true, - event_types: { - form_response: true, - }, - } - - if (secret) { - requestBody.secret = secret - } - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!typeformResponse.ok) { - const responseBody = await typeformResponse.json().catch(() => ({})) - const errorMessage = responseBody.description || responseBody.message || 'Unknown error' - - typeformLogger.error(`[${requestId}] Typeform API error: ${errorMessage}`, { - status: typeformResponse.status, - response: responseBody, - }) - - let userFriendlyMessage = 'Failed to create Typeform webhook' - if (typeformResponse.status === 401) { - userFriendlyMessage = - 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' - } else if (typeformResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' - } else if (typeformResponse.status === 404) { - userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' - } else if (responseBody.description || responseBody.message) { - userFriendlyMessage = `Typeform error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await typeformResponse.json() - typeformLogger.info( - `[${requestId}] Successfully created Typeform webhook for webhook ${webhook.id} with tag ${tag}`, - { webhookId: responseBody.id } - ) - - return tag - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Form ID') || - error.message.includes('Personal Access Token') || - error.message.includes('Typeform error')) - ) { - throw error - } - - typeformLogger.error( - `[${requestId}] Error creating Typeform webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Typeform webhook. Please try again.' - ) - } -} - -/** - * Delete a Typeform webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTypeformWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - - if (!formId || !apiKey) { - typeformLogger.warn( - `[${requestId}] Missing formId or apiKey for Typeform webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!typeformResponse.ok && typeformResponse.status !== 404) { - typeformLogger.warn( - `[${requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` - ) - } else { - typeformLogger.info(`[${requestId}] Successfully deleted Typeform webhook with tag ${tag}`) - } - } catch (error) { - typeformLogger.warn(`[${requestId}] Error deleting Typeform webhook (non-fatal)`, error) - } -} - -/** - * Delete a Calendly webhook subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteCalendlyWebhook(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) { - calendlyLogger.warn( - `[${requestId}] Missing apiKey for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - calendlyLogger.warn( - `[${requestId}] Missing externalId for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!calendlyResponse.ok && calendlyResponse.status !== 404) { - const responseBody = await calendlyResponse.json().catch(() => ({})) - calendlyLogger.warn( - `[${requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, - { response: responseBody } - ) - } else { - calendlyLogger.info( - `[${requestId}] Successfully deleted Calendly webhook subscription ${externalId}` - ) - } - } catch (error) { - calendlyLogger.warn(`[${requestId}] Error deleting Calendly webhook (non-fatal)`, error) - } -} - -/** - * Delete a Grain webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteGrainWebhook(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) { - grainLogger.warn( - `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - grainLogger.warn( - `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` - - const grainResponse = await fetch(grainApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }) - - if (!grainResponse.ok && grainResponse.status !== 404) { - const responseBody = await grainResponse.json().catch(() => ({})) - grainLogger.warn( - `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, - { response: responseBody } - ) - } else { - grainLogger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) - } - } catch (error) { - grainLogger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) - } -} - -/** - * Delete a Fathom webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteFathomWebhook(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) { - fathomLogger.warn( - `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - fathomLogger.warn( - `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) - if (!idValidation.isValid) { - fathomLogger.warn( - `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` - - const fathomResponse = await fetch(fathomApiUrl, { - method: 'DELETE', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!fathomResponse.ok && fathomResponse.status !== 404) { - fathomLogger.warn( - `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` - ) - } else { - fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) - } - } catch (error) { - fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) - } -} - -/** - * 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 - } - - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - const deleteById = async (id: string) => { - const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) - if (!validation.isValid) { - lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { - id: id.substring(0, 30), - }) - return - } - - const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` - 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 ${id}`) - } - } - - if (externalId) { - await deleteById(externalId) - return - } - - const notificationUrl = getNotificationUrl(webhook) - const listResponse = await fetch('https://api.lemlist.com/api/hooks', { - method: 'GET', - headers: { - Authorization: `Basic ${authString}`, - }, - }) - - if (!listResponse.ok) { - lemlistLogger.warn( - `[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, - { status: listResponse.status } - ) - return - } - - const listBody = await listResponse.json().catch(() => null) - const hooks: Array> = Array.isArray(listBody) - ? listBody - : listBody?.hooks || listBody?.data || [] - const matches = hooks.filter((hook) => { - const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url - return typeof targetUrl === 'string' && targetUrl === notificationUrl - }) - - if (matches.length === 0) { - lemlistLogger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { - notificationUrl, - }) - return - } - - for (const hook of matches) { - const hookId = hook?._id || hook?.id - if (typeof hookId === 'string' && hookId.length > 0) { - await deleteById(hookId) - } - } - } catch (error) { - lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) - } -} - -export async function deleteWebflowWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const siteId = config.siteId as string | undefined - const externalId = config.externalId as string | undefined - - if (!siteId) { - webflowLogger.warn( - `[${requestId}] Missing siteId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - webflowLogger.warn( - `[${requestId}] Missing externalId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { - webhookId: webhook.id, - siteId: siteId.substring(0, 30), - }) - return - } - - const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) - if (!webhookIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: externalId.substring(0, 30), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - webflowLogger.warn( - `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!webflowResponse.ok && webflowResponse.status !== 404) { - const responseBody = await webflowResponse.json().catch(() => ({})) - webflowLogger.warn( - `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, - { response: responseBody } - ) - } else { - webflowLogger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) - } - } catch (error) { - webflowLogger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) - } -} - -export async function createAttioWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise<{ externalId: string; webhookSecret: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { triggerId, credentialId } = providerConfig || {} - - if (!credentialId) { - attioLogger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` - ) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') - - let subscriptions: Array<{ event_type: string; filter: null }> = [] - if (triggerId === 'attio_webhook') { - const allEvents = new Set() - for (const events of Object.values(TRIGGER_EVENT_MAP)) { - for (const event of events) { - allEvents.add(event) - } - } - subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) - } else { - const events = TRIGGER_EVENT_MAP[triggerId] - if (!events || events.length === 0) { - attioLogger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Unknown Attio trigger type: ${triggerId}`) - } - subscriptions = events.map((event_type) => ({ event_type, filter: null })) - } - - const requestBody = { - data: { - target_url: notificationUrl, - subscriptions, - }, - } - - const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!attioResponse.ok) { - const errorBody = await attioResponse.json().catch(() => ({})) - attioLogger.error( - `[${requestId}] Failed to create webhook in Attio for webhook ${webhookData.id}. Status: ${attioResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Attio' - if (attioResponse.status === 401) { - userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' - } else if (attioResponse.status === 403) { - userFriendlyMessage = - 'Attio access denied. Please ensure your integration has webhook permissions.' - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await attioResponse.json() - const data = responseBody.data || responseBody - const webhookId = data.id?.webhook_id || data.webhook_id || data.id - const secret = data.secret - - if (!webhookId) { - attioLogger.error( - `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Attio webhook creation succeeded but no webhook ID was returned') - } - - if (!secret) { - attioLogger.warn( - `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookData.id}. Signature verification will be skipped.`, - { response: responseBody } - ) - } - - attioLogger.info( - `[${requestId}] Successfully created webhook in Attio for webhook ${webhookData.id}.`, - { - attioWebhookId: webhookId, - targetUrl: notificationUrl, - subscriptionCount: subscriptions.length, - status: data.status, - } - ) - - return { externalId: webhookId, webhookSecret: secret || '' } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - attioLogger.error( - `[${requestId}] Exception during Attio webhook creation for webhook ${webhookData.id}.`, - { message } - ) - throw error - } -} - -export async function deleteAttioWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const externalId = config.externalId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalId) { - attioLogger.warn( - `[${requestId}] Missing externalId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!credentialId) { - attioLogger.warn( - `[${requestId}] Missing credentialId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!attioResponse.ok && attioResponse.status !== 404) { - const responseBody = await attioResponse.json().catch(() => ({})) - attioLogger.warn( - `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, - { response: responseBody } - ) - } else { - attioLogger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) - } - } catch (error) { - attioLogger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) - } -} - -export async function createGrainWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string; eventTypes: string[] } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, viewId } = providerConfig || {} - - if (!apiKey) { - grainLogger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' - ) - } - - if (!viewId) { - grainLogger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { - webhookId: webhookData.id, - triggerId, - }) - throw new Error( - 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' - ) - } - - const actionMap: Record> = { - grain_item_added: ['added'], - grain_item_updated: ['updated'], - grain_recording_created: ['added'], - grain_recording_updated: ['updated'], - grain_highlight_created: ['added'], - grain_highlight_updated: ['updated'], - grain_story_created: ['added'], - } - - const eventTypeMap: Record = { - grain_webhook: [], - grain_item_added: [], - grain_item_updated: [], - grain_recording_created: ['recording_added'], - grain_recording_updated: ['recording_updated'], - grain_highlight_created: ['highlight_added'], - grain_highlight_updated: ['highlight_updated'], - grain_story_created: ['story_added'], - } - - const actions = actionMap[triggerId] ?? [] - const eventTypes = eventTypeMap[triggerId] ?? [] - - if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { - grainLogger.warn( - `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, - { - webhookId: webhookData.id, - } - ) - } - - grainLogger.info(`[${requestId}] Creating Grain webhook`, { - triggerId, - viewId, - actions, - eventTypes, - webhookId: webhookData.id, - }) - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' - - const requestBody: Record = { - version: 2, - hook_url: notificationUrl, - view_id: viewId, - } - if (actions.length > 0) { - requestBody.actions = actions - } - - const grainResponse = await fetch(grainApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await grainResponse.json() - - if (!grainResponse.ok || responseBody.error || responseBody.errors) { - const errorMessage = - responseBody.errors?.detail || - responseBody.error?.message || - responseBody.error || - responseBody.message || - 'Unknown Grain API error' - grainLogger.error( - `[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Grain' - if (grainResponse.status === 401) { - userFriendlyMessage = - 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' - } else if (grainResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Grain API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { - userFriendlyMessage = `Grain error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const grainWebhookId = responseBody.id - - if (!grainWebhookId) { - grainLogger.error( - `[${requestId}] Grain webhook creation response missing id for webhook ${webhookData.id}.`, - { - response: responseBody, - } - ) - throw new Error( - 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' - ) - } - - grainLogger.info( - `[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`, - { - grainWebhookId, - eventTypes, - } - ) - - return { id: grainWebhookId, eventTypes } - } catch (error: any) { - grainLogger.error( - `[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createFathomWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { - apiKey, - triggerId, - triggeredFor, - includeSummary, - includeTranscript, - includeActionItems, - includeCrmMatches, - } = providerConfig || {} - - if (!apiKey) { - fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Fathom API Key is required. Please provide your API key in the trigger configuration.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggeredForValue = triggeredFor || 'my_recordings' - - const toBool = (val: unknown, fallback: boolean): boolean => { - if (val === undefined) return fallback - return val === true || val === 'true' - } - - const requestBody: Record = { - destination_url: notificationUrl, - triggered_for: [triggeredForValue], - include_summary: toBool(includeSummary, true), - include_transcript: toBool(includeTranscript, false), - include_action_items: toBool(includeActionItems, false), - include_crm_matches: toBool(includeCrmMatches, false), - } - - fathomLogger.info(`[${requestId}] Creating Fathom webhook`, { - triggerId, - triggeredFor: triggeredForValue, - webhookId: webhookData.id, - }) - - const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { - method: 'POST', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await fathomResponse.json().catch(() => ({})) - - if (!fathomResponse.ok) { - const errorMessage = - (responseBody as Record).message || - (responseBody as Record).error || - 'Unknown Fathom API error' - fathomLogger.error( - `[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' - if (fathomResponse.status === 401) { - userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' - } else if (fathomResponse.status === 400) { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - if (!responseBody.id) { - fathomLogger.error( - `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.` - ) - throw new Error('Fathom webhook created but no ID returned. Please try again.') - } - - fathomLogger.info( - `[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`, - { - fathomWebhookId: responseBody.id, - } - ) - - return { id: responseBody.id } - } catch (error: any) { - fathomLogger.error( - `[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createLemlistWebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, campaignId } = providerConfig || {} - - if (!apiKey) { - lemlistLogger.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.' - ) - } - - 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, - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - lemlistLogger.info(`[${requestId}] Creating Lemlist webhook`, { - triggerId, - eventType, - hasCampaignId: !!campaignId, - webhookId: webhookData.id, - }) - - const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' - - const requestBody: Record = { - targetUrl: notificationUrl, - } - - if (eventType) { - requestBody.type = eventType - } - - if (campaignId) { - requestBody.campaignId = campaignId - } - - 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' - lemlistLogger.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) - } - - lemlistLogger.info( - `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, - { - lemlistWebhookId: responseBody._id, - } - ) - - return { id: responseBody._id } - } catch (error: any) { - lemlistLogger.error( - `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createAirtableWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = providerConfig || {} - - if (!baseId || !tableId) { - airtableLogger.warn( - `[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' - ) - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - throw new Error(baseIdValidation.error) - } - - const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') - if (!tableIdValidation.isValid) { - throw new Error(tableIdValidation.error) - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'airtable') - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` - ) - throw new Error( - 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - - const specification: any = { - options: { - filters: { - dataTypes: ['tableData'], - recordChangeScope: tableId, - }, - }, - } - - if (includeCellValuesInFieldIds === 'all') { - specification.options.includes = { - includeCellValuesInFieldIds: 'all', - } - } - - const requestBody: any = { - notificationUrl: notificationUrl, - specification: specification, - } - - const airtableResponse = await fetch(airtableApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await airtableResponse.json() - - if (!airtableResponse.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' - const errorType = responseBody.error?.type - airtableLogger.error( - `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`, - { type: errorType, message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' - if (airtableResponse.status === 404) { - userFriendlyMessage = - 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' - } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { - userFriendlyMessage = `Airtable error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - airtableLogger.info( - `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`, - { - airtableWebhookId: responseBody.id, - } - ) - return responseBody.id - } catch (error: any) { - airtableLogger.error( - `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createCalendlyWebhookSubscription( - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { apiKey, organization, triggerId } = providerConfig || {} - - if (!apiKey) { - calendlyLogger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' - ) - } - - if (!organization) { - calendlyLogger.warn( - `[${requestId}] Missing organization URI for Calendly webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' - ) - } - - if (!triggerId) { - calendlyLogger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger ID is required to create Calendly webhook') - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const eventTypeMap: Record = { - calendly_invitee_created: ['invitee.created'], - calendly_invitee_canceled: ['invitee.canceled'], - calendly_routing_form_submitted: ['routing_form_submission.created'], - calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], - } - - const events = eventTypeMap[triggerId] || ['invitee.created'] - - const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' - - const requestBody = { - url: notificationUrl, - events, - organization, - scope: 'organization', - } - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!calendlyResponse.ok) { - const errorBody = await calendlyResponse.json().catch(() => ({})) - const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' - calendlyLogger.error( - `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' - if (calendlyResponse.status === 401) { - userFriendlyMessage = - 'Calendly authentication failed. Please verify your Personal Access Token is correct.' - } else if (calendlyResponse.status === 403) { - userFriendlyMessage = - 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' - } else if (calendlyResponse.status === 404) { - userFriendlyMessage = - 'Calendly organization not found. Please verify the Organization URI is correct.' - } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { - userFriendlyMessage = `Calendly error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await calendlyResponse.json() - const webhookUri = responseBody.resource?.uri - - if (!webhookUri) { - calendlyLogger.error( - `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') - } - - const webhookId = webhookUri.split('/').pop() - - if (!webhookId) { - calendlyLogger.error( - `[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, - { - response: responseBody, - } - ) - throw new Error('Failed to extract webhook ID from Calendly response') - } - - calendlyLogger.info( - `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, - { - calendlyWebhookUri: webhookUri, - calendlyWebhookId: webhookId, - } - ) - return webhookId - } catch (error: any) { - calendlyLogger.error( - `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createWebflowWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { siteId, triggerId, collectionId, formName, credentialId } = providerConfig || {} - - if (!siteId) { - webflowLogger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Site ID is required to create Webflow webhook') - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - throw new Error(siteIdValidation.error) - } - - if (!triggerId) { - webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger type is required to create Webflow webhook') - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'webflow') - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` - ) - throw new Error( - 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggerTypeMap: Record = { - webflow_collection_item_created: 'collection_item_created', - webflow_collection_item_changed: 'collection_item_changed', - webflow_collection_item_deleted: 'collection_item_deleted', - webflow_form_submission: 'form_submission', - } - - const webflowTriggerType = triggerTypeMap[triggerId] - if (!webflowTriggerType) { - webflowLogger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Invalid Webflow trigger type: ${triggerId}`) - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` - - const requestBody: any = { - triggerType: webflowTriggerType, - url: notificationUrl, - } - - // Note: Webflow API only supports 'filter' for form_submission triggers. - if (formName && webflowTriggerType === 'form_submission') { - requestBody.filter = { - name: formName, - } - } - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await webflowResponse.json() - - if (!webflowResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' - webflowLogger.error( - `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`, - { message: errorMessage, response: responseBody } - ) - throw new Error(errorMessage) - } - - webflowLogger.info( - `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`, - { - webflowWebhookId: responseBody.id || responseBody._id, - } - ) - - return responseBody.id || responseBody._id - } catch (error: any) { - webflowLogger.error( - `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} +const logger = createLogger('WebhookProviderSubscriptions') type ExternalSubscriptionResult = { updatedProviderConfig: Record @@ -1986,21 +16,6 @@ type RecreateCheckInput = { nextConfig: Record } -/** Providers that create external webhook subscriptions */ -const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([ - 'airtable', - 'ashby', - 'attio', - 'calendly', - 'fathom', - 'webflow', - 'typeform', - 'grain', - 'lemlist', - 'telegram', - 'microsoft-teams', -]) - /** System-managed fields that shouldn't trigger recreation */ const SYSTEM_MANAGED_FIELDS = new Set([ 'externalId', @@ -2020,14 +35,16 @@ export function shouldRecreateExternalWebhookSubscription({ previousConfig, nextConfig, }: RecreateCheckInput): boolean { + const hasSubscription = (provider: string) => { + const handler = getProviderHandler(provider) + return Boolean(handler.createSubscription) + } + if (previousProvider !== nextProvider) { - return ( - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(previousProvider) || - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider) - ) + return hasSubscription(previousProvider) || hasSubscription(nextProvider) } - if (!PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider)) { + if (!hasSubscription(nextProvider)) { return false } @@ -2052,287 +69,60 @@ export function shouldRecreateExternalWebhookSubscription({ export async function createExternalWebhookSubscription( request: NextRequest, - webhookData: any, - workflow: any, + webhookData: Record, + workflow: Record, userId: string, requestId: string ): Promise { const provider = webhookData.provider as string const providerConfig = (webhookData.providerConfig as Record) || {} - let updatedProviderConfig = providerConfig - let externalSubscriptionCreated = false + const handler = getProviderHandler(provider) - if (provider === 'ashby') { - const result = await createAshbyWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - secretToken: result.secretToken, - } - externalSubscriptionCreated = true - } - } else if (provider === 'airtable') { - const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'attio') { - const result = await createAttioWebhookSubscription(userId, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.externalId, - webhookSecret: result.webhookSecret, - } - externalSubscriptionCreated = true - } - } else if (provider === 'calendly') { - const externalId = await createCalendlyWebhookSubscription(webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'microsoft-teams') { - const subscriptionId = await createTeamsSubscription(request, webhookData, workflow, requestId) - if (subscriptionId) { - updatedProviderConfig = { ...updatedProviderConfig, externalSubscriptionId: subscriptionId } - externalSubscriptionCreated = true - } - } else if (provider === 'telegram') { - await createTelegramWebhook(request, webhookData, requestId) - externalSubscriptionCreated = true - } else if (provider === 'webflow') { - const externalId = await createWebflowWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'typeform') { - const usedTag = await createTypeformWebhook(request, webhookData, requestId) - if (!updatedProviderConfig.webhookTag && usedTag) { - updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag } - } - externalSubscriptionCreated = true - } else if (provider === 'fathom') { - const result = await createFathomWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } - } else if (provider === 'grain') { - const result = await createGrainWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - eventTypes: result.eventTypes, - } - externalSubscriptionCreated = true - } - } else if (provider === 'lemlist') { - const result = await createLemlistWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } + if (!handler.createSubscription) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } } - return { updatedProviderConfig, externalSubscriptionCreated } -} + const result = await handler.createSubscription({ + webhook: webhookData, + workflow, + userId, + requestId, + request, + }) -/** - * Clean up external webhook subscriptions for a webhook - * Handles Airtable, Attio, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup - * Don't fail deletion if cleanup fails - */ -export async function cleanupExternalWebhook( - webhook: any, - workflow: any, - requestId: string -): Promise { - if (webhook.provider === 'ashby') { - await deleteAshbyWebhook(webhook, requestId) - } else if (webhook.provider === 'airtable') { - await deleteAirtableWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'attio') { - await deleteAttioWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'microsoft-teams') { - await deleteTeamsSubscription(webhook, workflow, requestId) - } else if (webhook.provider === 'telegram') { - await deleteTelegramWebhook(webhook, requestId) - } else if (webhook.provider === 'typeform') { - await deleteTypeformWebhook(webhook, requestId) - } else if (webhook.provider === 'calendly') { - await deleteCalendlyWebhook(webhook, requestId) - } else if (webhook.provider === 'webflow') { - await deleteWebflowWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'fathom') { - await deleteFathomWebhook(webhook, requestId) - } else if (webhook.provider === 'grain') { - await deleteGrainWebhook(webhook, requestId) - } else if (webhook.provider === 'lemlist') { - await deleteLemlistWebhook(webhook, requestId) + if (!result) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } + } + + return { + updatedProviderConfig: { ...providerConfig, ...result.providerConfigUpdates }, + externalSubscriptionCreated: true, } } /** - * Creates a webhook subscription in Ashby via webhook.create API. - * Ashby uses Basic Auth and one webhook per event type (webhookType). + * Clean up external webhook subscriptions for a webhook. + * Errors are swallowed — cleanup failure should not block webhook deletion. */ -export async function createAshbyWebhookSubscription( - webhookData: any, +export async function cleanupExternalWebhook( + webhook: Record, + workflow: Record, requestId: string -): Promise<{ id: string; secretToken: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId } = providerConfig || {} - - if (!apiKey) { - throw new Error( - 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' - ) - } - - if (!triggerId) { - throw new Error('Trigger ID is required to create Ashby webhook.') - } - - const webhookTypeMap: Record = { - ashby_application_submit: 'applicationSubmit', - ashby_candidate_stage_change: 'candidateStageChange', - ashby_candidate_hire: 'candidateHire', - ashby_candidate_delete: 'candidateDelete', - ashby_job_create: 'jobCreate', - ashby_offer_create: 'offerCreate', - } - - const webhookType = webhookTypeMap[triggerId] - if (!webhookType) { - throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - ashbyLogger.info(`[${requestId}] Creating Ashby webhook`, { - triggerId, - webhookType, - webhookId: webhookData.id, - }) - - const secretToken = generateId() - - const requestBody: Record = { - requestUrl: notificationUrl, - webhookType, - secretToken, - } - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await ashbyResponse.json().catch(() => ({})) - - if (!ashbyResponse.ok || !responseBody.success) { - const errorMessage = - responseBody.errorInfo?.message || responseBody.message || 'Unknown Ashby API error' - - let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' - if (ashbyResponse.status === 401) { - userFriendlyMessage = - 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' - } else if (ashbyResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' - } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { - userFriendlyMessage = `Ashby error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const externalId = responseBody.results?.id - if (!externalId) { - throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') - } +): Promise { + const provider = webhook.provider as string + const handler = getProviderHandler(provider) - ashbyLogger.info( - `[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}` - ) - return { id: externalId, secretToken } - } catch (error: any) { - ashbyLogger.error( - `[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error + if (!handler.deleteSubscription) { + return } -} -/** - * Deletes an Ashby webhook subscription via webhook.delete API. - * Ashby uses POST with webhookId in the body (not DELETE method). - */ -export async function deleteAshbyWebhook(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) { - ashbyLogger.warn( - `[${requestId}] Missing apiKey for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - ashbyLogger.warn( - `[${requestId}] Missing externalId for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ webhookId: externalId }), - }) - - if (ashbyResponse.ok) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Successfully deleted Ashby webhook subscription ${externalId}` - ) - } else if (ashbyResponse.status === 404) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` - ) - } else { - const responseBody = await ashbyResponse.json().catch(() => ({})) - ashbyLogger.warn( - `[${requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, - { response: responseBody } - ) - } + await handler.deleteSubscription({ webhook, workflow, requestId }) } catch (error) { - ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error) + logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, { + provider, + webhookId: webhook.id, + error: error instanceof Error ? error.message : String(error), + }) } } diff --git a/apps/sim/lib/webhooks/provider-utils.ts b/apps/sim/lib/webhooks/provider-utils.ts deleted file mode 100644 index c475d1205e3..00000000000 --- a/apps/sim/lib/webhooks/provider-utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Provider-specific unique identifier extractors for webhook idempotency - */ - -function extractSlackIdentifier(body: any): string | null { - if (body.event_id) { - return body.event_id - } - - if (body.event?.ts && body.team_id) { - return `${body.team_id}:${body.event.ts}` - } - - return null -} - -function extractTwilioIdentifier(body: any): string | null { - return body.MessageSid || body.CallSid || null -} - -function extractStripeIdentifier(body: any): string | null { - if (body.id && body.object === 'event') { - return body.id - } - return null -} - -function extractHubSpotIdentifier(body: any): string | null { - if (Array.isArray(body) && body.length > 0 && body[0]?.eventId) { - return String(body[0].eventId) - } - return null -} - -function extractLinearIdentifier(body: any): string | null { - if (body.action && body.data?.id) { - return `${body.action}:${body.data.id}` - } - return null -} - -function extractJiraIdentifier(body: any): string | null { - if (body.webhookEvent && (body.issue?.id || body.project?.id)) { - return `${body.webhookEvent}:${body.issue?.id || body.project?.id}` - } - return null -} - -function extractMicrosoftTeamsIdentifier(body: any): string | null { - if (body.value && Array.isArray(body.value) && body.value.length > 0) { - const notification = body.value[0] - if (notification.subscriptionId && notification.resourceData?.id) { - return `${notification.subscriptionId}:${notification.resourceData.id}` - } - } - return null -} - -function extractAirtableIdentifier(body: any): string | null { - if (body.cursor && typeof body.cursor === 'string') { - return body.cursor - } - return null -} - -function extractGrainIdentifier(body: any): string | null { - if (body.type && body.data?.id) { - return `${body.type}:${body.data.id}` - } - return null -} - -const PROVIDER_EXTRACTORS: Record string | null> = { - slack: extractSlackIdentifier, - twilio: extractTwilioIdentifier, - twilio_voice: extractTwilioIdentifier, - stripe: extractStripeIdentifier, - hubspot: extractHubSpotIdentifier, - linear: extractLinearIdentifier, - jira: extractJiraIdentifier, - 'microsoft-teams': extractMicrosoftTeamsIdentifier, - airtable: extractAirtableIdentifier, - grain: extractGrainIdentifier, -} - -export function extractProviderIdentifierFromBody(provider: string, body: any): string | null { - if (!body || typeof body !== 'object') { - return null - } - - const extractor = PROVIDER_EXTRACTORS[provider] - return extractor ? extractor(body) : null -} diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts new file mode 100644 index 00000000000..80fecf73854 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -0,0 +1,760 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { validateAirtableId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Airtable') + +interface AirtableChange { + tableId: string + recordId: string + changeType: 'created' | 'updated' + changedFields: Record + previousFields?: Record +} + +interface AirtableTableChanges { + createdRecordsById?: Record }> + changedRecordsById?: Record< + string, + { + current?: { cellValuesByFieldId?: Record } + previous?: { cellValuesByFieldId?: Record } + } + > + destroyedRecordIds?: string[] +} + +/** + * Process Airtable payloads + */ +async function fetchAndProcessAirtablePayloads( + webhookData: Record, + workflowData: Record, + requestId: string // Original request ID from the ping, used for the final execution log +) { + // Logging handles all error logging + let currentCursor: number | null = null + let mightHaveMore = true + let payloadsFetched = 0 + let apiCallCount = 0 + // Use a Map to consolidate changes per record ID + const consolidatedChangesMap = new Map() + // Capture raw payloads from Airtable for exposure to workflows + const allPayloads = [] + const localProviderConfig = { + ...((webhookData.providerConfig as Record) || {}), + } as Record + + try { + const baseId = localProviderConfig.baseId + const airtableWebhookId = localProviderConfig.externalId + + if (!baseId || !airtableWebhookId) { + logger.error( + `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` + ) + return + } + + const credentialId = localProviderConfig.credentialId as string | undefined + if (!credentialId) { + logger.error( + `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` + ) + return + } + + const resolvedAirtable = await resolveOAuthAccountId(credentialId) + if (!resolvedAirtable) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` + ) + return + } + + let ownerUserId: string | null = null + try { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedAirtable.accountId)) + .limit(1) + ownerUserId = rows.length ? rows[0].userId : null + } catch (_e) { + ownerUserId = null + } + + if (!ownerUserId) { + logger.error( + `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` + ) + return + } + + const storedCursor = localProviderConfig.externalWebhookCursor + + if (storedCursor === undefined || storedCursor === null) { + logger.info( + `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` + ) + localProviderConfig.externalWebhookCursor = null + + try { + await db + .update(webhook) + .set({ + providerConfig: { + ...localProviderConfig, + externalWebhookCursor: null, + }, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id as string)) + + localProviderConfig.externalWebhookCursor = null + logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) + } catch (initError: unknown) { + const err = initError as Error + logger.error(`[${requestId}] Failed to initialize cursor in DB`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + } + } + + if (storedCursor && typeof storedCursor === 'number') { + currentCursor = storedCursor + } else { + currentCursor = null + } + + let accessToken: string | null = null + try { + accessToken = await refreshAccessTokenIfNeeded( + resolvedAirtable.accountId, + ownerUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` + ) + throw new Error('Airtable access token not found.') + } + } catch (tokenError: unknown) { + const err = tokenError as Error + logger.error( + `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, + { + error: err.message, + stack: err.stack, + credentialId, + } + ) + return + } + + const airtableApiBase = 'https://api.airtable.com/v0' + + while (mightHaveMore) { + apiCallCount++ + // Safety break + if (apiCallCount > 10) { + mightHaveMore = false + break + } + + const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` + const queryParams = new URLSearchParams() + if (currentCursor !== null) { + queryParams.set('cursor', currentCursor.toString()) + } + const fullUrl = `${apiUrl}?${queryParams.toString()}` + + try { + const fetchStartTime = Date.now() + const response = await fetch(fullUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + const responseBody = await response.json() + + if (!response.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || + responseBody.error || + `Airtable API error Status ${response.status}` + logger.error( + `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, + { + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + } + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + + const receivedPayloads = responseBody.payloads || [] + + if (receivedPayloads.length > 0) { + payloadsFetched += receivedPayloads.length + // Keep the raw payloads for later exposure to the workflow + for (const p of receivedPayloads) { + allPayloads.push(p) + } + let changeCount = 0 + for (const payload of receivedPayloads) { + if (payload.changedTablesById) { + for (const [tableId, tableChangesUntyped] of Object.entries( + payload.changedTablesById + )) { + const tableChanges = tableChangesUntyped as AirtableTableChanges + + if (tableChanges.createdRecordsById) { + const createdCount = Object.keys(tableChanges.createdRecordsById).length + changeCount += createdCount + + for (const [recordId, recordData] of Object.entries( + tableChanges.createdRecordsById + )) { + const existingChange = consolidatedChangesMap.get(recordId) + if (existingChange) { + // Record was created and possibly updated within the same batch + existingChange.changedFields = { + ...existingChange.changedFields, + ...(recordData.cellValuesByFieldId || {}), + } + // Keep changeType as 'created' if it started as created + } else { + // New creation + consolidatedChangesMap.set(recordId, { + tableId: tableId, + recordId: recordId, + changeType: 'created', + changedFields: recordData.cellValuesByFieldId || {}, + }) + } + } + } + + // Handle updated records + if (tableChanges.changedRecordsById) { + const updatedCount = Object.keys(tableChanges.changedRecordsById).length + changeCount += updatedCount + + for (const [recordId, recordData] of Object.entries( + tableChanges.changedRecordsById + )) { + const existingChange = consolidatedChangesMap.get(recordId) + const currentFields = recordData.current?.cellValuesByFieldId || {} + + if (existingChange) { + // Existing record was updated again + existingChange.changedFields = { + ...existingChange.changedFields, + ...currentFields, + } + // Ensure type is 'updated' if it was previously 'created' + existingChange.changeType = 'updated' + // Do not update previousFields again + } else { + // First update for this record in the batch + const newChange: AirtableChange = { + tableId: tableId, + recordId: recordId, + changeType: 'updated', + changedFields: currentFields, + } + if (recordData.previous?.cellValuesByFieldId) { + newChange.previousFields = recordData.previous.cellValuesByFieldId + } + consolidatedChangesMap.set(recordId, newChange) + } + } + } + // TODO: Handle deleted records (`destroyedRecordIds`) if needed + } + } + } + } + + const nextCursor = responseBody.cursor + mightHaveMore = responseBody.mightHaveMore || false + + if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { + currentCursor = nextCursor + + // Follow exactly the old implementation - use awaited update instead of parallel + const updatedConfig = { + ...localProviderConfig, + externalWebhookCursor: currentCursor, + } + try { + // Force a complete object update to ensure consistency in serverless env + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, // Use full object + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id as string)) + + localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too + } catch (dbError: unknown) { + const err = dbError as Error + logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { + webhookId: webhookData.id, + cursor: currentCursor, + error: err.message, + }) + // Error logging handled by logging session + mightHaveMore = false + throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly + } + } else if (!nextCursor || typeof nextCursor !== 'number') { + logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { + webhookId: webhookData.id, + apiCall: apiCallCount, + receivedCursor: nextCursor, + }) + mightHaveMore = false + } else if (nextCursor === currentCursor) { + mightHaveMore = false // Explicitly stop if cursor hasn't changed + } + } catch (fetchError: unknown) { + logger.error( + `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, + fetchError + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + } + // Convert map values to array for final processing + const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) + logger.info( + `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` + ) + + if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { + try { + // Build input exposing raw payloads and consolidated changes + const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null + const input: Record = { + payloads: allPayloads, + latestPayload, + // Consolidated, simplified changes for convenience + airtableChanges: finalConsolidatedChanges, + // Include webhook metadata for resolver fallbacks + webhook: { + data: { + provider: 'airtable', + providerConfig: webhookData.providerConfig, + payload: latestPayload, + }, + }, + } + + // CRITICAL EXECUTION TRACE POINT + logger.info( + `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + timestamp: new Date().toISOString(), + firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', + } + ) + + // Return the processed input for the trigger.dev task to handle + logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + rawPayloadCount: allPayloads.length, + timestamp: new Date().toISOString(), + }) + + return input + } catch (processingError: unknown) { + const err = processingError as Error + logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + workflowId: workflowData.id, + error: err.message, + stack: err.stack, + timestamp: new Date().toISOString(), + }) + + throw processingError + } + } else { + // DEBUG: Log when no changes are found + logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + workflowId: workflowData.id, + apiCallCount, + webhookId: webhookData.id, + }) + } + } catch (error) { + // Catch any unexpected errors during the setup/polling logic itself + logger.error( + `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, + { + webhookId: webhookData.id, + workflowId: workflowData.id, + error: (error as Error).message, + } + ) + // Error logging handled by logging session + } +} + +export const airtableHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = config as { + baseId?: string + tableId?: string + includeCellValuesInFieldIds?: string + credentialId?: string + } + + if (!baseId || !tableId) { + logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' + ) + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + throw new Error(baseIdValidation.error) + } + + const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') + if (!tableIdValidation.isValid) { + throw new Error(tableIdValidation.error) + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'airtable') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` + ) + throw new Error( + 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + + const specification: Record = { + options: { + filters: { + dataTypes: ['tableData'], + recordChangeScope: tableId, + }, + }, + } + + if (includeCellValuesInFieldIds === 'all') { + ;(specification.options as Record).includes = { + includeCellValuesInFieldIds: 'all', + } + } + + const requestBody: Record = { + notificationUrl: notificationUrl, + specification: specification, + } + + const airtableResponse = await fetch(airtableApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await airtableResponse.json() + + if (!airtableResponse.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' + const errorType = responseBody.error?.type + logger.error( + `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`, + { type: errorType, message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' + if (airtableResponse.status === 404) { + userFriendlyMessage = + 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' + } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { + userFriendlyMessage = `Airtable error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + logger.info( + `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`, + { + airtableWebhookId: responseBody.id, + } + ) + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const { baseId, externalId } = config as { + baseId?: string + externalId?: string + } + + if (!baseId) { + logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { + webhookId: webhookRecord.id, + }) + return + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { + webhookId: webhookRecord.id, + baseId: baseId.substring(0, 20), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, + { webhookId: webhookRecord.id } + ) + return + } + + let resolvedExternalId: string | undefined = externalId + + if (!resolvedExternalId) { + try { + const expectedNotificationUrl = getNotificationUrl(webhookRecord) + + const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + const listResp = await fetch(listUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const listBody = await listResp.json().catch(() => null) + + if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { + const match = listBody.webhooks.find((w: Record) => { + const url: string | undefined = w?.notificationUrl as string | undefined + if (!url) return false + return ( + url === expectedNotificationUrl || + url.endsWith(`/api/webhooks/trigger/${webhookRecord.path}`) + ) + }) + if (match?.id) { + resolvedExternalId = match.id as string + logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { + baseId, + externalId: resolvedExternalId, + }) + } else { + logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { + baseId, + expectedNotificationUrl, + }) + } + } else { + logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, { + baseId, + status: listResp.status, + body: listBody, + }) + } + } catch (e: unknown) { + logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { + error: (e as Error)?.message, + }) + } + } + + if (!resolvedExternalId) { + logger.info(`[${requestId}] Airtable externalId not found; skipping remote deletion`, { + baseId, + }) + return + } + + const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: resolvedExternalId.substring(0, 20), + }) + return + } + + const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` + const airtableResponse = await fetch(airtableDeleteUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!airtableResponse.ok) { + let responseBody: unknown = null + try { + responseBody = await airtableResponse.json() + } catch { + // Ignore parse errors + } + + logger.warn( + `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, + { baseId, externalId: resolvedExternalId, response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { + baseId, + externalId: resolvedExternalId, + }) + } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Error deleting Airtable webhook`, { + webhookId: webhookRecord.id, + error: err.message, + stack: err.stack, + }) + } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (typeof obj.cursor === 'string') { + return obj.cursor + } + return null + }, + + async formatInput({ webhook, workflow, requestId }: FormatInputContext) { + logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) + + const webhookData = { + id: webhook.id, + provider: webhook.provider, + providerConfig: webhook.providerConfig, + } + + const mockWorkflow = { + id: workflow.id, + userId: workflow.userId, + } + + const airtableInput = await fetchAndProcessAirtablePayloads( + webhookData, + mockWorkflow, + requestId + ) + + if (airtableInput) { + logger.info(`[${requestId}] Executing workflow with Airtable changes`) + return { input: airtableInput } + } + + logger.info(`[${requestId}] No Airtable changes to process`) + return { input: null, skip: { message: 'No Airtable changes to process' } } + }, +} diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts new file mode 100644 index 00000000000..ce044495009 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -0,0 +1,208 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import { generateId } from '@/lib/core/utils/uuid' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Ashby') + +function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { + try { + if (!secretToken || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Ashby signature:', error) + return false + } +} + +export const ashbyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + ...((b.data as Record) || {}), + action: b.action, + data: b.data || {}, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'secretToken', + headerName: 'ashby-signature', + validateFn: validateAshbySignature, + providerLabel: 'Ashby', + }), + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, triggerId } = providerConfig as { + apiKey?: string + triggerId?: string + } + + if (!apiKey) { + throw new Error( + 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' + ) + } + + if (!triggerId) { + throw new Error('Trigger ID is required to create Ashby webhook.') + } + + const webhookTypeMap: Record = { + ashby_application_submit: 'applicationSubmit', + ashby_candidate_stage_change: 'candidateStageChange', + ashby_candidate_hire: 'candidateHire', + ashby_candidate_delete: 'candidateDelete', + ashby_job_create: 'jobCreate', + ashby_offer_create: 'offerCreate', + } + + const webhookType = webhookTypeMap[triggerId] + if (!webhookType) { + throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + logger.info(`[${ctx.requestId}] Creating Ashby webhook`, { + triggerId, + webhookType, + webhookId: ctx.webhook.id, + }) + + const secretToken = generateId() + + const requestBody: Record = { + requestUrl: notificationUrl, + webhookType, + secretToken, + } + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record + + if (!ashbyResponse.ok || !responseBody.success) { + const errorInfo = responseBody.errorInfo as Record | undefined + const errorMessage = + errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error' + + let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' + if (ashbyResponse.status === 401) { + userFriendlyMessage = + 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' + } else if (ashbyResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' + } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { + userFriendlyMessage = `Ashby error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const results = responseBody.results as Record | undefined + const externalId = results?.id as string | undefined + if (!externalId) { + throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') + } + + logger.info( + `[${ctx.requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}` + ) + return { providerConfigUpdates: { externalId, secretToken } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ webhookId: externalId }), + }) + + if (ashbyResponse.ok) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` + ) + } else if (ashbyResponse.status === 404) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` + ) + } else { + const responseBody = await ashbyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, + { response: responseBody } + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts new file mode 100644 index 00000000000..883d979334f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -0,0 +1,366 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Attio') + +function validateAttioSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Attio signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Attio signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Attio signature:', error) + return false + } +} + +export const attioHandler: WebhookProviderHandler = { + verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + + if (!secret) { + logger.debug( + `[${requestId}] Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification` + ) + } else { + const signature = request.headers.get('Attio-Signature') + + if (!signature) { + logger.warn(`[${requestId}] Attio webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Attio signature', { + status: 401, + }) + } + + const isValidSignature = validateAttioSignature(secret, signature, rawBody) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Attio signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse('Unauthorized - Invalid Attio signature', { + status: 401, + }) + } + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'attio_webhook') { + const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') + if (!isAttioPayloadMatch(triggerId, obj)) { + const event = getAttioEvent(obj) + const eventType = event?.event_type as string | undefined + logger.debug( + `[${requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + + return true + }, + + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { triggerId, credentialId } = config as { + triggerId?: string + credentialId?: string + } + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` + ) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') + + let subscriptions: Array<{ event_type: string; filter: null }> = [] + if (triggerId === 'attio_webhook') { + const allEvents = new Set() + for (const events of Object.values(TRIGGER_EVENT_MAP)) { + for (const event of events) { + allEvents.add(event) + } + } + subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) + } else { + const events = TRIGGER_EVENT_MAP[triggerId as string] + if (!events || events.length === 0) { + logger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Unknown Attio trigger type: ${triggerId}`) + } + subscriptions = events.map((event_type) => ({ event_type, filter: null })) + } + + const requestBody = { + data: { + target_url: notificationUrl, + subscriptions, + }, + } + + const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!attioResponse.ok) { + const errorBody = await attioResponse.json().catch(() => ({})) + logger.error( + `[${requestId}] Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Attio' + if (attioResponse.status === 401) { + userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' + } else if (attioResponse.status === 403) { + userFriendlyMessage = + 'Attio access denied. Please ensure your integration has webhook permissions.' + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await attioResponse.json() + const data = responseBody.data || responseBody + const webhookId = data.id?.webhook_id || data.webhook_id || data.id + const secret = data.secret + + if (!webhookId) { + logger.error( + `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`, + { response: responseBody } + ) + throw new Error('Attio webhook creation succeeded but no webhook ID was returned') + } + + if (!secret) { + logger.warn( + `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`, + { response: responseBody } + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Attio for webhook ${webhookRecord.id}.`, + { + attioWebhookId: webhookId, + targetUrl: notificationUrl, + subscriptionCount: subscriptions.length, + status: data.status, + } + ) + + return { providerConfigUpdates: { externalId: webhookId, webhookSecret: secret || '' } } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + logger.error( + `[${requestId}] Exception during Attio webhook creation for webhook ${webhookRecord.id}.`, + { message } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const externalId = config.externalId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!attioResponse.ok && attioResponse.status !== 404) { + const responseBody = await attioResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractAttioRecordData, + extractAttioRecordUpdatedData, + extractAttioRecordMergedData, + extractAttioNoteData, + extractAttioTaskData, + extractAttioCommentData, + extractAttioListEntryData, + extractAttioListEntryUpdatedData, + extractAttioListData, + extractAttioWorkspaceMemberData, + extractAttioGenericData, + } = await import('@/triggers/attio/utils') + + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId === 'attio_record_updated') { + return { input: extractAttioRecordUpdatedData(b) } + } + if (triggerId === 'attio_record_merged') { + return { input: extractAttioRecordMergedData(b) } + } + if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { + return { input: extractAttioRecordData(b) } + } + if (triggerId?.startsWith('attio_note_')) { + return { input: extractAttioNoteData(b) } + } + if (triggerId?.startsWith('attio_task_')) { + return { input: extractAttioTaskData(b) } + } + if (triggerId?.startsWith('attio_comment_')) { + return { input: extractAttioCommentData(b) } + } + if (triggerId === 'attio_list_entry_updated') { + return { input: extractAttioListEntryUpdatedData(b) } + } + if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { + return { input: extractAttioListEntryData(b) } + } + if ( + triggerId === 'attio_list_created' || + triggerId === 'attio_list_updated' || + triggerId === 'attio_list_deleted' + ) { + return { input: extractAttioListData(b) } + } + if (triggerId === 'attio_workspace_member_created') { + return { input: extractAttioWorkspaceMemberData(b) } + } + return { input: extractAttioGenericData(b) } + }, +} diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts new file mode 100644 index 00000000000..b018b16f581 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Calcom') + +function validateCalcomSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Cal.com signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + let providedSignature: string + if (signature.startsWith('sha256=')) { + providedSignature = signature.substring(7) + } else { + providedSignature = signature + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Cal.com signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Cal.com signature:', error) + return false + } +} + +export const calcomHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Cal-Signature-256', + validateFn: validateCalcomSignature, + providerLabel: 'Cal.com', + }), +} diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts new file mode 100644 index 00000000000..7fcca4a8e8f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -0,0 +1,211 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Calendly') + +export const calendlyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + event: b.event, + created_at: b.created_at, + created_by: b.created_by, + payload: b.payload, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, organization, triggerId } = providerConfig as { + apiKey?: string + organization?: string + triggerId?: string + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' + ) + } + + if (!organization) { + logger.warn(`[${ctx.requestId}] Missing organization URI for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' + ) + } + + if (!triggerId) { + logger.warn(`[${ctx.requestId}] Missing triggerId for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error('Trigger ID is required to create Calendly webhook') + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + + const eventTypeMap: Record = { + calendly_invitee_created: ['invitee.created'], + calendly_invitee_canceled: ['invitee.canceled'], + calendly_routing_form_submitted: ['routing_form_submission.created'], + calendly_webhook: [ + 'invitee.created', + 'invitee.canceled', + 'routing_form_submission.created', + ], + } + + const events = eventTypeMap[triggerId] || ['invitee.created'] + + const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' + + const requestBody = { + url: notificationUrl, + events, + organization, + scope: 'organization', + } + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!calendlyResponse.ok) { + const errorBody = await calendlyResponse.json().catch(() => ({})) + const errorMessage = + (errorBody as Record).message || + (errorBody as Record).title || + 'Unknown Calendly API error' + logger.error( + `[${ctx.requestId}] Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' + if (calendlyResponse.status === 401) { + userFriendlyMessage = + 'Calendly authentication failed. Please verify your Personal Access Token is correct.' + } else if (calendlyResponse.status === 403) { + userFriendlyMessage = + 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' + } else if (calendlyResponse.status === 404) { + userFriendlyMessage = + 'Calendly organization not found. Please verify the Organization URI is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { + userFriendlyMessage = `Calendly error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = (await calendlyResponse.json()) as Record + const resource = responseBody.resource as Record | undefined + const webhookUri = resource?.uri as string | undefined + + if (!webhookUri) { + logger.error( + `[${ctx.requestId}] Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`, + { response: responseBody } + ) + throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') + } + + const webhookId = webhookUri.split('/').pop() + + if (!webhookId) { + logger.error( + `[${ctx.requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, + { + response: responseBody, + } + ) + throw new Error('Failed to extract webhook ID from Calendly response') + } + + logger.info( + `[${ctx.requestId}] Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`, + { + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + } + ) + return { providerConfigUpdates: { externalId: webhookId } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!calendlyResponse.ok && calendlyResponse.status !== 404) { + const responseBody = await calendlyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, + { response: responseBody } + ) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Calendly webhook subscription ${externalId}` + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Calendly webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts new file mode 100644 index 00000000000..1beb8a6814f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Circleback') + +function validateCirclebackSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Circleback signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Circleback signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Circleback signature:', error) + return false + } +} + +export const circlebackHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + id: b.id, + name: b.name, + createdAt: b.createdAt, + duration: b.duration, + url: b.url, + recordingUrl: b.recordingUrl, + tags: b.tags || [], + icalUid: b.icalUid, + attendees: b.attendees || [], + notes: b.notes || '', + actionItems: b.actionItems || [], + transcript: b.transcript || [], + insights: b.insights || {}, + meeting: b, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-signature', + validateFn: validateCirclebackSignature, + providerLabel: 'Circleback', + }), +} diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts new file mode 100644 index 00000000000..79fda7c0529 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { validateJiraSignature } from '@/lib/webhooks/providers/jira' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Confluence') + +export const confluenceHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Confluence', + }), + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractPageData, + extractCommentData, + extractBlogData, + extractAttachmentData, + extractSpaceData, + extractLabelData, + } = await import('@/triggers/confluence/utils') + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId?.startsWith('confluence_comment_')) { + return { input: extractCommentData(body) } + } + if (triggerId?.startsWith('confluence_blog_')) { + return { input: extractBlogData(body) } + } + if (triggerId?.startsWith('confluence_attachment_')) { + return { input: extractAttachmentData(body) } + } + if (triggerId?.startsWith('confluence_space_')) { + return { input: extractSpaceData(body) } + } + if (triggerId?.startsWith('confluence_label_')) { + return { input: extractLabelData(body) } + } + if (triggerId === 'confluence_webhook') { + const b = body as Record + return { + input: { + timestamp: b.timestamp, + userAccountId: b.userAccountId, + accountType: b.accountType, + page: b.page || null, + comment: b.comment || null, + blog: b.blog || (b as Record).blogpost || null, + attachment: b.attachment || null, + space: b.space || null, + label: b.label || null, + content: b.content || null, + }, + } + } + return { input: extractPageData(body) } + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId) { + const { isConfluencePayloadMatch } = await import('@/triggers/confluence/utils') + if (!isConfluencePayloadMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + message: 'Payload does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts new file mode 100644 index 00000000000..c705d00353f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -0,0 +1,173 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Fathom') + +export const fathomHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const triggeredFor = providerConfig.triggeredFor as string | undefined + const includeSummary = providerConfig.includeSummary as unknown + const includeTranscript = providerConfig.includeTranscript as unknown + const includeActionItems = providerConfig.includeActionItems as unknown + const includeCrmMatches = providerConfig.includeCrmMatches as unknown + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Fathom API Key is required. Please provide your API key in the trigger configuration.' + ) + } + + const notificationUrl = getNotificationUrl(webhook) + + const triggeredForValue = triggeredFor || 'my_recordings' + + const toBool = (val: unknown, fallback: boolean): boolean => { + if (val === undefined) return fallback + return val === true || val === 'true' + } + + const requestBody: Record = { + destination_url: notificationUrl, + triggered_for: [triggeredForValue], + include_summary: toBool(includeSummary, true), + include_transcript: toBool(includeTranscript, false), + include_action_items: toBool(includeActionItems, false), + include_crm_matches: toBool(includeCrmMatches, false), + } + + logger.info(`[${requestId}] Creating Fathom webhook`, { + triggerId, + triggeredFor: triggeredForValue, + webhookId: webhook.id, + }) + + const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await fathomResponse.json().catch(() => ({}))) as Record< + string, + unknown + > + + if (!fathomResponse.ok) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Fathom API error' + logger.error( + `[${requestId}] Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' + if (fathomResponse.status === 401) { + userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' + } else if (fathomResponse.status === 400) { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + if (!responseBody.id) { + logger.error( + `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhook.id}.` + ) + throw new Error('Fathom webhook created but no ID returned. Please try again.') + } + + logger.info( + `[${requestId}] Successfully created webhook in Fathom for webhook ${webhook.id}.`, + { + fathomWebhookId: responseBody.id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Fathom webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) + if (!idValidation.isValid) { + logger.warn( + `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` + + const fathomResponse = await fetch(fathomApiUrl, { + method: 'DELETE', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + }) + + if (!fathomResponse.ok && fathomResponse.status !== 404) { + logger.warn( + `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` + ) + } else { + logger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts new file mode 100644 index 00000000000..f3245d5cd3c --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -0,0 +1,63 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Fireflies') + +function validateFirefliesSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Fireflies signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Fireflies signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Fireflies signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Fireflies signature:', error) + return false + } +} + +export const firefliesHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + meetingId: (b.meetingId || '') as string, + eventType: (b.eventType || 'Transcription completed') as string, + clientReferenceId: (b.clientReferenceId || '') as string, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-hub-signature', + validateFn: validateFirefliesSignature, + providerLabel: 'Fireflies', + }), +} diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts new file mode 100644 index 00000000000..712cd09d12e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -0,0 +1,145 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Generic') + +export const genericHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + if (providerConfig.requireAuth) { + const configToken = providerConfig.token as string | undefined + if (!configToken) { + return new NextResponse('Unauthorized - Authentication required but no token configured', { + status: 401, + }) + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, configToken, secretHeaderName)) { + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } + } + + const allowedIps = providerConfig.allowedIps + if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) { + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) { + logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`) + return new NextResponse('Forbidden - IP not allowed', { + status: 403, + }) + } + } + + return null + }, + + enrichHeaders({ body, providerConfig }: EventFilterContext, headers: Record) { + const idempotencyField = providerConfig.idempotencyField as string | undefined + if (idempotencyField && body) { + const value = idempotencyField + .split('.') + .reduce( + (acc: unknown, key: string) => + acc && typeof acc === 'object' ? (acc as Record)[key] : undefined, + body + ) + if (value !== undefined && value !== null && typeof value !== 'object') { + headers['x-sim-idempotency-key'] = String(value) + } + } + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.responseMode === 'custom') { + const rawCode = Number(providerConfig.responseStatusCode) || 200 + const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 + const responseBody = (providerConfig.responseBody as string | undefined)?.trim() + + if (!responseBody) { + return new NextResponse(null, { status: statusCode }) + } + + try { + const parsed = JSON.parse(responseBody) + return NextResponse.json(parsed, { status: statusCode }) + } catch { + return new NextResponse(responseBody, { + status: statusCode, + headers: { 'Content-Type': 'text/plain' }, + }) + } + } + + return null + }, + + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + + async processInputFiles({ + input, + blocks, + blockId, + workspaceId, + workflowId, + executionId, + requestId, + userId, + }: ProcessFilesContext) { + const triggerBlock = blocks[blockId] as Record | undefined + const subBlocks = triggerBlock?.subBlocks as Record | undefined + const inputFormatBlock = subBlocks?.inputFormat as Record | undefined + + if (inputFormatBlock?.value) { + const inputFormat = inputFormatBlock.value as Array<{ + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' + }> + + const fileFields = inputFormat.filter((field) => field.type === 'file[]') + + if (fileFields.length > 0) { + const { processExecutionFiles } = await import('@/lib/execution/files') + const executionContext = { + workspaceId, + workflowId, + executionId, + } + + for (const fileField of fileFields) { + const fieldValue = input[fileField.name] + + if (fieldValue && typeof fieldValue === 'object') { + const uploadedFiles = await processExecutionFiles( + fieldValue, + executionContext, + requestId, + userId + ) + + if (uploadedFiles.length > 0) { + input[fileField.name] = uploadedFiles + logger.info( + `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` + ) + } + } + } + } + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts new file mode 100644 index 00000000000..a0fd90f2e6d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -0,0 +1,124 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:GitHub') + +function validateGitHubSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('GitHub signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + let algorithm: 'sha256' | 'sha1' + let providedSignature: string + if (signature.startsWith('sha256=')) { + algorithm = 'sha256' + providedSignature = signature.substring(7) + } else if (signature.startsWith('sha1=')) { + algorithm = 'sha1' + providedSignature = signature.substring(5) + } else { + logger.warn('GitHub signature has invalid format', { + signature: `${signature.substring(0, 10)}...`, + }) + return false + } + const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') + logger.debug('GitHub signature comparison', { + algorithm, + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating GitHub signature:', error) + return false + } +} + +export const githubHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = + request.headers.get('X-Hub-Signature-256') || request.headers.get('X-Hub-Signature') + if (!signature) { + logger.warn(`[${requestId}] GitHub webhook missing signature header`) + return new NextResponse('Unauthorized - Missing GitHub signature', { status: 401 }) + } + + if (!validateGitHubSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] GitHub signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + usingSha256: !!request.headers.get('X-Hub-Signature-256'), + }) + return new NextResponse('Unauthorized - Invalid GitHub signature', { status: 401 }) + } + + return null + }, + + async formatInput({ body, headers }: FormatInputContext): Promise { + const b = body as Record + const eventType = headers['x-github-event'] || 'unknown' + const ref = (b?.ref as string) || '' + const branch = ref.replace('refs/heads/', '') + return { + input: { ...b, event_type: eventType, action: (b?.action || '') as string, branch }, + } + }, + + async matchEvent({ + webhook, + workflow, + body, + request, + requestId, + providerConfig, + }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'github_webhook') { + const eventType = request.headers.get('x-github-event') + const action = obj.action as string | undefined + + const { isGitHubEventMatch } = await import('@/triggers/github/utils') + if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) { + logger.debug( + `[${requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + receivedAction: action, + } + ) + return false + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts new file mode 100644 index 00000000000..650ebfb0980 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -0,0 +1,117 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Gmail') + +export const gmailHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + const resolvedGmail = await resolveOAuthAccountId(credentialId) + if (!resolvedGmail) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedGmail.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedGmail.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } + + const maxEmailsPerPoll = + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25 + + const pollingInterval = + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5 + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Gmail polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts new file mode 100644 index 00000000000..67e7fd8c997 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:GoogleForms') + +export const googleFormsHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const normalizeAnswers = (src: unknown): Record => { + if (!src || typeof src !== 'object') return {} + const out: Record = {} + for (const [k, v] of Object.entries(src as Record)) { + if (Array.isArray(v)) { + out[k] = v.length === 1 ? v[0] : v + } else { + out[k] = v + } + } + return out + } + const responseId = (b?.responseId || b?.id || '') as string + const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string + const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string + const formId = (b?.formId || providerConfig.formId || '') as string + const includeRaw = providerConfig.includeRawPayload !== false + return { + input: { + responseId, + createTime, + lastSubmittedTime, + formId, + answers: normalizeAnswers(b?.answers), + ...(includeRaw ? { raw: b?.raw ?? b } : {}), + }, + } + }, + + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + const expectedToken = providerConfig.token as string | undefined + if (!expectedToken) { + return null + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, expectedToken, secretHeaderName)) { + logger.warn(`[${requestId}] Google Forms webhook authentication failed`) + return new NextResponse('Unauthorized - Invalid secret', { status: 401 }) + } + + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts new file mode 100644 index 00000000000..02bb0122076 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -0,0 +1,251 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Grain') + +export const grainHandler: WebhookProviderHandler = { + handleReachabilityTest(body: unknown, requestId: string) { + const obj = body as Record | null + const isVerificationRequest = !obj || Object.keys(obj).length === 0 || !obj.type + if (isVerificationRequest) { + logger.info( + `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` + ) + return NextResponse.json({ + status: 'ok', + message: 'Webhook endpoint verified', + }) + } + return null + }, + + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Grain', logger) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { input: { type: b.type, user_id: b.user_id, data: b.data || {} } } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.type && data?.id) { + return `${obj.type}:${data.id}` + } + return null + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const viewId = providerConfig.viewId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' + ) + } + + if (!viewId) { + logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { + webhookId: webhook.id, + triggerId, + }) + throw new Error( + 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' + ) + } + + const actionMap: Record> = { + grain_item_added: ['added'], + grain_item_updated: ['updated'], + grain_recording_created: ['added'], + grain_recording_updated: ['updated'], + grain_highlight_created: ['added'], + grain_highlight_updated: ['updated'], + grain_story_created: ['added'], + } + + const eventTypeMap: Record = { + grain_webhook: [], + grain_item_added: [], + grain_item_updated: [], + grain_recording_created: ['recording_added'], + grain_recording_updated: ['recording_updated'], + grain_highlight_created: ['highlight_added'], + grain_highlight_updated: ['highlight_updated'], + grain_story_created: ['story_added'], + } + + const actions = actionMap[triggerId ?? ''] ?? [] + const eventTypes = eventTypeMap[triggerId ?? ''] ?? [] + + if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { + logger.warn( + `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, + { + webhookId: webhook.id, + } + ) + } + + logger.info(`[${requestId}] Creating Grain webhook`, { + triggerId, + viewId, + actions, + eventTypes, + webhookId: webhook.id, + }) + + const notificationUrl = getNotificationUrl(webhook) + + const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' + + const requestBody: Record = { + version: 2, + hook_url: notificationUrl, + view_id: viewId, + } + if (actions.length > 0) { + requestBody.actions = actions + } + + const grainResponse = await fetch(grainApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await grainResponse.json()) as Record + + if (!grainResponse.ok || responseBody.error || responseBody.errors) { + const errors = responseBody.errors as Record | undefined + const error = responseBody.error as Record | string | undefined + const errorMessage = + errors?.detail || + (typeof error === 'object' ? error?.message : undefined) || + (typeof error === 'string' ? error : undefined) || + (responseBody.message as string) || + 'Unknown Grain API error' + logger.error( + `[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Grain' + if (grainResponse.status === 401) { + userFriendlyMessage = + 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' + } else if (grainResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Grain API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { + userFriendlyMessage = `Grain error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const grainWebhookId = responseBody.id as string | undefined + + if (!grainWebhookId) { + logger.error( + `[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`, + { + response: responseBody, + } + ) + throw new Error( + 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`, + { + grainWebhookId, + eventTypes, + } + ) + + return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Grain webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` + + const grainResponse = await fetch(grainApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + + if (!grainResponse.ok && grainResponse.status !== 404) { + const responseBody = await grainResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts new file mode 100644 index 00000000000..2591ee40175 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:HubSpot') + +export const hubspotHandler: WebhookProviderHandler = { + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId?.startsWith('hubspot_')) { + const events = Array.isArray(body) ? body : [body] + const firstEvent = events[0] as Record | undefined + const subscriptionType = firstEvent?.subscriptionType as string | undefined + + const { isHubSpotContactEventMatch } = await import('@/triggers/hubspot/utils') + if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { + logger.debug( + `[${requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + return false + } + + logger.info( + `[${requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + } + + return true + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const events = Array.isArray(b) ? b : [b] + const event = events[0] as Record | undefined + if (!event) { + logger.warn('HubSpot webhook received with empty payload') + return { input: null } + } + logger.info('Formatting HubSpot webhook input', { + subscriptionType: event.subscriptionType, + objectId: event.objectId, + portalId: event.portalId, + }) + return { + input: { payload: body, provider: 'hubspot', providerConfig: webhook.providerConfig }, + } + }, + + extractIdempotencyId(body: unknown) { + if (Array.isArray(body) && body.length > 0) { + const first = body[0] as Record + if (first?.eventId) { + return String(first.eventId) + } + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts new file mode 100644 index 00000000000..aff02b2a341 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -0,0 +1,84 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Imap') + +export const imapHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { + input: { + messageId: b.messageId, + subject: b.subject, + from: b.from, + to: b.to, + cc: b.cc, + date: b.date, + bodyText: b.bodyText, + bodyHtml: b.bodyHtml, + mailbox: b.mailbox, + hasAttachments: b.hasAttachments, + attachments: b.attachments, + email: b.email, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { + logger.error( + `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` + ) + return false + } + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + port: providerConfig.port || '993', + secure: providerConfig.secure !== false, + mailbox: providerConfig.mailbox || 'INBOX', + searchCriteria: providerConfig.searchCriteria || 'UNSEEN', + markAsRead: providerConfig.markAsRead || false, + includeAttachments: providerConfig.includeAttachments !== false, + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure IMAP polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/index.ts b/apps/sim/lib/webhooks/providers/index.ts new file mode 100644 index 00000000000..dd43ec6c223 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/index.ts @@ -0,0 +1,24 @@ +export { getProviderHandler } from '@/lib/webhooks/providers/registry' +export type { + AuthContext, + EventFilterContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +import { getProviderHandler } from '@/lib/webhooks/providers/registry' + +/** + * Extract a provider-specific unique identifier from the webhook body for idempotency. + */ +export function extractProviderIdentifierFromBody(provider: string, body: unknown): string | null { + if (!body || typeof body !== 'object') { + return null + } + + const handler = getProviderHandler(provider) + return handler.extractIdempotencyId?.(body) ?? null +} diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts new file mode 100644 index 00000000000..1520238d11e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -0,0 +1,104 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Jira') + +export function validateJiraSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Jira signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Jira signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Jira signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Jira signature:', error) + return false + } +} + +export const jiraHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Jira', + }), + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { extractIssueData, extractCommentData, extractWorklogData } = await import( + '@/triggers/jira/utils' + ) + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'jira_issue_commented') { + return { input: extractCommentData(body) } + } + if (triggerId === 'jira_worklog_created') { + return { input: extractWorklogData(body) } + } + return { input: extractIssueData(body) } + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'jira_webhook') { + const webhookEvent = obj.webhookEvent as string | undefined + const issueEventTypeName = obj.issue_event_type_name as string | undefined + + const { isJiraEventMatch } = await import('@/triggers/jira/utils') + if (!isJiraEventMatch(triggerId, webhookEvent || '', issueEventTypeName)) { + logger.debug( + `[${requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: webhookEvent, + } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const issue = obj.issue as Record | undefined + const project = obj.project as Record | undefined + if (obj.webhookEvent && (issue?.id || project?.id)) { + return `${obj.webhookEvent}:${issue?.id || project?.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts new file mode 100644 index 00000000000..2127512f9d3 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -0,0 +1,218 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Lemlist') + +export const lemlistHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const campaignId = providerConfig.campaignId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' + ) + } + + 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, + } + + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(webhook) + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + logger.info(`[${requestId}] Creating Lemlist webhook`, { + triggerId, + eventType, + hasCampaignId: !!campaignId, + webhookId: webhook.id, + }) + + const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' + + const requestBody: Record = { + targetUrl: notificationUrl, + } + + if (eventType) { + requestBody.type = eventType + } + + if (campaignId) { + requestBody.campaignId = campaignId + } + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await lemlistResponse.json()) as Record + + if (!lemlistResponse.ok || responseBody.error) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Lemlist API error' + logger.error( + `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhook.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 ${webhook.id}.`, + { + lemlistWebhookId: responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + const deleteById = async (id: string) => { + const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) + if (!validation.isValid) { + logger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { + id: id.substring(0, 30), + }) + return + } + + const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!lemlistResponse.ok && lemlistResponse.status !== 404) { + const responseBody = await lemlistResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) + } + } + + if (externalId) { + await deleteById(externalId) + return + } + + const notificationUrl = getNotificationUrl(webhook) + const listResponse = await fetch('https://api.lemlist.com/api/hooks', { + method: 'GET', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!listResponse.ok) { + logger.warn(`[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, { + status: listResponse.status, + }) + return + } + + const listBody = (await listResponse.json().catch(() => null)) as + | Record + | Array> + | null + const hooks: Array> = Array.isArray(listBody) + ? listBody + : ((listBody as Record)?.hooks as Array>) || + ((listBody as Record)?.data as Array>) || + [] + const matches = hooks.filter((hook) => { + const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url + return typeof targetUrl === 'string' && targetUrl === notificationUrl + }) + + if (matches.length === 0) { + logger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { + notificationUrl, + }) + return + } + + for (const hook of matches) { + const hookId = (hook?._id || hook?.id) as string | undefined + if (typeof hookId === 'string' && hookId.length > 0) { + await deleteById(hookId) + } + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts new file mode 100644 index 00000000000..9372b8d6009 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Linear') + +function validateLinearSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Linear signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Linear signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Linear signature:', error) + return false + } +} + +export const linearHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'Linear-Signature', + validateFn: validateLinearSignature, + providerLabel: 'Linear', + }), + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + action: b.action || '', + type: b.type || '', + webhookId: b.webhookId || '', + webhookTimestamp: b.webhookTimestamp || 0, + organizationId: b.organizationId || '', + createdAt: b.createdAt || '', + actor: b.actor || null, + data: b.data || null, + updatedFrom: b.updatedFrom || null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.action && data?.id) { + return `${obj.action}:${data.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts new file mode 100644 index 00000000000..8270eb93e01 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -0,0 +1,787 @@ +import crypto from 'crypto' +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:MicrosoftTeams') + +function validateMicrosoftTeamsSignature( + hmacSecret: string, + signature: string, + body: string +): boolean { + try { + if (!hmacSecret || !signature || !body) { + return false + } + if (!signature.startsWith('HMAC ')) { + return false + } + const providedSignature = signature.substring(5) + const secretBytes = Buffer.from(hmacSecret, 'base64') + const bodyBytes = Buffer.from(body, 'utf8') + const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Microsoft Teams signature:', error) + return false + } +} + +function parseFirstNotification( + body: unknown +): { subscriptionId: string; messageId: string } | null { + const obj = body as Record + const value = obj.value as unknown[] | undefined + if (!Array.isArray(value) || value.length === 0) { + return null + } + + const notification = value[0] as Record + const subscriptionId = notification.subscriptionId as string | undefined + const resourceData = notification.resourceData as Record | undefined + const messageId = resourceData?.id as string | undefined + + if (subscriptionId && messageId) { + return { subscriptionId, messageId } + } + return null +} + +async function fetchWithDNSPinning( + url: string, + accessToken: string, + requestId: string +): Promise { + try { + const urlValidation = await validateUrlWithDNS(url, 'contentUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url }) + return null + } + const headers: Record = {} + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { headers }) + return response + } catch (error) { + logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { + error: error instanceof Error ? error.message : String(error), + url: sanitizeUrlForLog(url), + }) + return null + } +} + +/** + * Format Microsoft Teams Graph change notification + */ +async function formatTeamsGraphNotification( + body: Record, + foundWebhook: Record, + foundWorkflow: { id: string; userId: string }, + request: { headers: Map } +): Promise { + const notification = (body.value as unknown[])?.[0] as Record | undefined + if (!notification) { + logger.warn('Received empty Teams notification body') + return null + } + const changeType = (notification.changeType as string) || 'created' + const resource = (notification.resource as string) || '' + const subscriptionId = (notification.subscriptionId as string) || '' + + let chatId: string | null = null + let messageId: string | null = null + + const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (fullMatch) { + chatId = fullMatch[1] + messageId = fullMatch[2] + } + + if (!chatId || !messageId) { + const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (quotedMatch) { + chatId = quotedMatch[1] + messageId = quotedMatch[2] + } + } + + if (!chatId || !messageId) { + const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) + const rdId = ((body?.value as unknown[])?.[0] as Record)?.resourceData as + | Record + | undefined + const rdIdValue = rdId?.id as string | undefined + if (collectionMatch && rdIdValue) { + chatId = collectionMatch[1] + messageId = rdIdValue + } + } + + if ( + (!chatId || !messageId) && + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ) { + const resourceData = ((body.value as unknown[])[0] as Record) + .resourceData as Record + const odataId = resourceData['@odata.id'] + if (typeof odataId === 'string') { + const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (odataMatch) { + chatId = odataMatch[1] + messageId = odataMatch[2] + } + } + } + + if (!chatId || !messageId) { + logger.warn('Could not resolve chatId/messageId from Teams notification', { + resource, + hasResourceDataId: Boolean( + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ), + valueLength: Array.isArray(body?.value) ? (body.value as unknown[]).length : 0, + keys: Object.keys(body || {}), + }) + return { + from: null, + message: { raw: body }, + activity: body, + conversation: null, + } + } + const resolvedChatId = chatId as string + const resolvedMessageId = messageId as string + const providerConfig = (foundWebhook?.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + let message: Record | null = null + const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = + [] + let accessToken: string | null = null + + if (!credentialId) { + logger.error('Missing credentialId for Teams chat subscription', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + webhookId: foundWebhook?.id, + blockId: foundWebhook?.blockId, + providerConfig, + }) + } else { + try { + const resolved = await resolveOAuthAccountId(credentialId as string) + if (!resolved) { + logger.error('Teams credential could not be resolved', { credentialId }) + } else { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + effectiveUserId, + 'teams-graph-notification' + ) + } + } + + if (accessToken) { + const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` + const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (res.ok) { + message = (await res.json()) as Record + + if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) { + const attachments = Array.isArray(message?.attachments) + ? (message.attachments as Record[]) + : [] + for (const att of attachments) { + try { + const contentUrl = + typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined + const contentTypeHint = + typeof att?.contentType === 'string' ? (att.contentType as string) : undefined + let attachmentName = (att?.name as string) || 'teams-attachment' + + if (!contentUrl) continue + + let buffer: Buffer | null = null + let mimeType = 'application/octet-stream' + + if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { + try { + const directRes = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment' + ) + + if (directRes?.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else if (directRes) { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` + const graphRes = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (graphRes.ok) { + const arrayBuffer = await graphRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + graphRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } + } catch { + continue + } + } else if ( + contentUrl.includes('1drv.ms') || + contentUrl.includes('onedrive.live.com') || + contentUrl.includes('onedrive.com') || + contentUrl.includes('my.microsoftpersonalcontent.com') + ) { + try { + let shareToken: string | null = null + + if (contentUrl.includes('1drv.ms')) { + const urlParts = contentUrl.split('/').pop() + if (urlParts) shareToken = urlParts + } else if (contentUrl.includes('resid=')) { + const urlParams = new URL(contentUrl).searchParams + const resId = urlParams.get('resid') + if (resId) shareToken = resId + } + + if (!shareToken) { + const base64Url = Buffer.from(contentUrl, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } else if (!shareToken.startsWith('u!')) { + const base64Url = Buffer.from(shareToken, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataRes.ok) { + const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` + const directRes = await fetch(directUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } else { + const metadata = (await metadataRes.json()) as Record + const downloadUrl = metadata['@microsoft.graph.downloadUrl'] as + | string + | undefined + + if (downloadUrl) { + const downloadRes = await fetchWithDNSPinning( + downloadUrl, + '', + 'teams-onedrive-download' + ) + + if (downloadRes?.ok) { + const arrayBuffer = await downloadRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + const fileInfo = metadata.file as Record | undefined + mimeType = + downloadRes.headers.get('content-type') || + (fileInfo?.mimeType as string | undefined) || + contentTypeHint || + 'application/octet-stream' + + if (metadata.name && metadata.name !== attachmentName) { + attachmentName = metadata.name as string + } + } else { + continue + } + } else { + continue + } + } + } catch { + continue + } + } else { + try { + const ares = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment-generic' + ) + if (ares?.ok) { + const arrayBuffer = await ares.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + ares.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } + } catch { + continue + } + } + + if (!buffer) continue + + const size = buffer.length + + rawAttachments.push({ + name: attachmentName, + data: buffer, + contentType: mimeType, + size, + }) + } catch { + /* skip attachment on error */ + } + } + } + } + } + } catch (error) { + logger.error('Failed to fetch Teams message', { + error, + chatId: resolvedChatId, + messageId: resolvedMessageId, + }) + } + } + + if (!message) { + logger.warn('No message data available for Teams notification', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + hasCredential: !!credentialId, + }) + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: '', + text: '', + created_at: '', + attachments: [], + } + } + + const messageText = (message.body as Record)?.content || '' + const from = ((message.from as Record)?.user as Record) || {} + const createdAt = (message.createdDateTime as string) || '' + + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: (from.displayName as string) || '', + text: messageText, + created_at: createdAt, + attachments: rawAttachments, + } +} + +export const microsoftTeamsHandler: WebhookProviderHandler = { + handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const validationToken = url.searchParams.get('validationToken') + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + return null + }, + + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + if (providerConfig.hmacSecret) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('HMAC ')) { + logger.warn( + `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` + ) + return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) + } + + if ( + !validateMicrosoftTeamsSignature(providerConfig.hmacSecret as string, authHeader, rawBody) + ) { + logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) + return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) + } + } + + return null + }, + + formatErrorResponse(error: string, status: number) { + return NextResponse.json({ type: 'message', text: error }, { status }) + }, + + enrichHeaders({ body }: EventFilterContext, headers: Record) { + const parsed = parseFirstNotification(body) + if (parsed) { + headers['x-teams-notification-id'] = `${parsed.subscriptionId}:${parsed.messageId}` + } + }, + + extractIdempotencyId(body: unknown) { + const parsed = parseFirstNotification(body) + return parsed ? `${parsed.subscriptionId}:${parsed.messageId}` : null + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.triggerId === 'microsoftteams_chat_subscription') { + return new NextResponse(null, { status: 202 }) + } + + return NextResponse.json({ type: 'message', text: 'Sim' }) + }, + + formatQueueErrorResponse() { + return NextResponse.json( + { type: 'message', text: 'Webhook processing failed' }, + { status: 500 } + ) + }, + + async createSubscription({ + webhook, + workflow, + userId, + requestId, + request, + }: SubscriptionContext): Promise { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return undefined + } + + const credentialId = config.credentialId as string | undefined + const chatId = config.chatId as string | undefined + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' + ) + } + + if (!chatId) { + logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`) + throw new Error( + 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' + ) + } + + const existingSubscriptionId = config.externalSubscriptionId as string | undefined + if (existingSubscriptionId) { + try { + const checkRes = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, + { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (checkRes.ok) { + logger.info( + `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: existingSubscriptionId } } + } + } catch { + logger.debug(`[${requestId}] Existing subscription check failed, will create new one`) + } + } + + const notificationUrl = getNotificationUrl(webhook) + const resource = `/chats/${chatId}/messages` + + const maxLifetimeMinutes = 4230 + const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() + + const body = { + changeType: 'created,updated', + notificationUrl, + lifecycleNotificationUrl: notificationUrl, + resource, + includeResourceData: false, + expirationDateTime, + clientState: webhook.id, + } + + try { + const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + const payload = await res.json() + if (!res.ok) { + const errorMessage = + payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' + logger.error( + `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, + { + status: res.status, + error: payload.error, + } + ) + + let userFriendlyMessage = 'Failed to create Teams subscription' + if (res.status === 401 || res.status === 403) { + userFriendlyMessage = + 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' + } else if (res.status === 404) { + userFriendlyMessage = + 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' + } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { + userFriendlyMessage = `Teams error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: payload.id as string } } + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('credentials') || + error.message.includes('Chat ID') || + error.message.includes('authenticate')) + ) { + throw error + } + + logger.error( + `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Teams subscription. Please try again.' + ) + } + }, + + async deleteSubscription({ + webhook, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return + } + + const externalSubscriptionId = config.externalSubscriptionId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalSubscriptionId || !credentialId) { + logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` + ) + return + } + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (res.ok || res.status === 404) { + logger.info( + `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` + ) + } else { + const errorBody = await res.text() + logger.warn( + `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` + ) + } + } catch (error) { + logger.error( + `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, + error + ) + } + }, + + async formatInput({ + body, + webhook, + workflow, + headers, + requestId, + }: FormatInputContext): Promise { + const b = body as Record + const value = b?.value as unknown[] | undefined + + if (value && Array.isArray(value) && value.length > 0) { + const mockRequest = { + headers: new Map(Object.entries(headers)), + } as unknown as import('next/server').NextRequest + const result = await formatTeamsGraphNotification( + b, + webhook, + workflow, + mockRequest as unknown as { headers: Map } + ) + return { input: result } + } + + const messageText = (b?.text as string) || '' + const messageId = (b?.id as string) || '' + const timestamp = (b?.timestamp as string) || (b?.localTimestamp as string) || '' + const from = (b?.from || {}) as Record + const conversation = (b?.conversation || {}) as Record + + return { + input: { + from: { + id: (from.id || '') as string, + name: (from.name || '') as string, + aadObjectId: (from.aadObjectId || '') as string, + }, + message: { + raw: { + attachments: b?.attachments || [], + channelData: b?.channelData || {}, + conversation: b?.conversation || {}, + text: messageText, + messageType: (b?.type || 'message') as string, + channelId: (b?.channelId || '') as string, + timestamp, + }, + }, + activity: b || {}, + conversation: { + id: (conversation.id || '') as string, + name: (conversation.name || '') as string, + isGroup: (conversation.isGroup || false) as boolean, + tenantId: (conversation.tenantId || '') as string, + aadObjectId: (conversation.aadObjectId || '') as string, + conversationType: (conversation.conversationType || '') as string, + }, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts new file mode 100644 index 00000000000..01f9656bc15 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -0,0 +1,113 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Outlook') + +export const outlookHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + const resolvedOutlook = await resolveOAuthAccountId(credentialId) + if (!resolvedOutlook) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedOutlook.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedOutlook.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll: + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25, + pollingInterval: + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Outlook polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts new file mode 100644 index 00000000000..00ae58a21b1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { airtableHandler } from '@/lib/webhooks/providers/airtable' +import { ashbyHandler } from '@/lib/webhooks/providers/ashby' +import { attioHandler } from '@/lib/webhooks/providers/attio' +import { calcomHandler } from '@/lib/webhooks/providers/calcom' +import { calendlyHandler } from '@/lib/webhooks/providers/calendly' +import { circlebackHandler } from '@/lib/webhooks/providers/circleback' +import { confluenceHandler } from '@/lib/webhooks/providers/confluence' +import { fathomHandler } from '@/lib/webhooks/providers/fathom' +import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' +import { genericHandler } from '@/lib/webhooks/providers/generic' +import { githubHandler } from '@/lib/webhooks/providers/github' +import { gmailHandler } from '@/lib/webhooks/providers/gmail' +import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' +import { grainHandler } from '@/lib/webhooks/providers/grain' +import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' +import { imapHandler } from '@/lib/webhooks/providers/imap' +import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' +import { linearHandler } from '@/lib/webhooks/providers/linear' +import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' +import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { rssHandler } from '@/lib/webhooks/providers/rss' +import { slackHandler } from '@/lib/webhooks/providers/slack' +import { stripeHandler } from '@/lib/webhooks/providers/stripe' +import { telegramHandler } from '@/lib/webhooks/providers/telegram' +import { twilioHandler } from '@/lib/webhooks/providers/twilio' +import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice' +import { typeformHandler } from '@/lib/webhooks/providers/typeform' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' +import { webflowHandler } from '@/lib/webhooks/providers/webflow' +import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp' + +const logger = createLogger('WebhookProviderRegistry') + +const PROVIDER_HANDLERS: Record = { + airtable: airtableHandler, + ashby: ashbyHandler, + attio: attioHandler, + calendly: calendlyHandler, + calcom: calcomHandler, + circleback: circlebackHandler, + confluence: confluenceHandler, + fireflies: firefliesHandler, + generic: genericHandler, + gmail: gmailHandler, + github: githubHandler, + google_forms: googleFormsHandler, + fathom: fathomHandler, + grain: grainHandler, + hubspot: hubspotHandler, + imap: imapHandler, + jira: jiraHandler, + lemlist: lemlistHandler, + linear: linearHandler, + 'microsoft-teams': microsoftTeamsHandler, + outlook: outlookHandler, + rss: rssHandler, + slack: slackHandler, + stripe: stripeHandler, + telegram: telegramHandler, + twilio: twilioHandler, + twilio_voice: twilioVoiceHandler, + typeform: typeformHandler, + webflow: webflowHandler, + whatsapp: whatsappHandler, +} + +/** + * Default handler for unknown/future providers. + * Uses timing-safe comparison for bearer token validation. + */ +const defaultHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }) { + const token = providerConfig.token + if (typeof token === 'string') { + if (!verifyTokenAuth(request, token)) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + return null + }, +} + +/** Look up the provider handler, falling back to the default bearer token handler. */ +export function getProviderHandler(provider: string): WebhookProviderHandler { + return PROVIDER_HANDLERS[provider] ?? defaultHandler +} diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts new file mode 100644 index 00000000000..e517fd1cda1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Rss') + +export const rssHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'item' in b) { + return { + input: { + title: b.title, + link: b.link, + pubDate: b.pubDate, + item: b.item, + feed: b.feed, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + lastCheckedTimestamp: now.toISOString(), + lastSeenGuids: [], + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure RSS polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts new file mode 100644 index 00000000000..1bcedd628b9 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -0,0 +1,282 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Slack') + +const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +const SLACK_MAX_FILES = 15 + +const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) + +async function resolveSlackFileInfo( + fileId: string, + botToken: string +): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { + try { + const response = await fetch( + `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, + { headers: { Authorization: `Bearer ${botToken}` } } + ) + const data = (await response.json()) as { + ok: boolean + error?: string + file?: Record + } + if (!data.ok || !data.file) { + logger.warn('Slack files.info failed', { fileId, error: data.error }) + return null + } + return { + url_private: data.file.url_private as string | undefined, + name: data.file.name as string | undefined, + mimetype: data.file.mimetype as string | undefined, + size: data.file.size as number | undefined, + } + } catch (error) { + logger.error('Error calling Slack files.info', { + fileId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +async function downloadSlackFiles( + rawFiles: unknown[], + botToken: string +): Promise> { + const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) + const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + + for (const file of filesToProcess) { + const f = file as Record + let urlPrivate = f.url_private as string | undefined + let fileName = f.name as string | undefined + let fileMimeType = f.mimetype as string | undefined + let fileSize = f.size as number | undefined + + if (!urlPrivate && f.id) { + const resolved = await resolveSlackFileInfo(f.id as string, botToken) + if (resolved?.url_private) { + urlPrivate = resolved.url_private + fileName = fileName || resolved.name + fileMimeType = fileMimeType || resolved.mimetype + fileSize = fileSize ?? resolved.size + } + } + + if (!urlPrivate) { + logger.warn('Slack file has no url_private and could not be resolved, skipping', { + fileId: f.id, + }) + continue + } + + const reportedSize = Number(fileSize) || 0 + if (reportedSize > SLACK_MAX_FILE_SIZE) { + logger.warn('Slack file exceeds size limit, skipping', { + fileId: f.id, + size: reportedSize, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + try { + const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') + if (!urlValidation.isValid) { + logger.warn('Slack file url_private failed DNS validation, skipping', { + fileId: f.id, + error: urlValidation.error, + }) + continue + } + + const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + + if (!response.ok) { + logger.warn('Failed to download Slack file, skipping', { + fileId: f.id, + status: response.status, + }) + continue + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + if (buffer.length > SLACK_MAX_FILE_SIZE) { + logger.warn('Downloaded Slack file exceeds size limit, skipping', { + fileId: f.id, + actualSize: buffer.length, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + downloaded.push({ + name: fileName || 'download', + data: buffer.toString('base64'), + mimeType: fileMimeType || 'application/octet-stream', + size: buffer.length, + }) + } catch (error) { + logger.error('Error downloading Slack file, skipping', { + fileId: f.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return downloaded +} + +async function fetchSlackMessageText( + channel: string, + messageTs: string, + botToken: string +): Promise { + try { + const params = new URLSearchParams({ channel, timestamp: messageTs }) + const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + const data = (await response.json()) as { + ok: boolean + error?: string + type?: string + message?: { text?: string } + } + if (!data.ok) { + logger.warn('Slack reactions.get failed — message text unavailable', { + channel, + messageTs, + error: data.error, + }) + return '' + } + return data.message?.text ?? '' + } catch (error) { + logger.warn('Error fetching Slack message text', { + channel, + messageTs, + error: error instanceof Error ? error.message : String(error), + }) + return '' + } +} + +/** + * Handle Slack verification challenges + */ +export function handleSlackChallenge(body: unknown): NextResponse | null { + const obj = body as Record + if (obj.type === 'url_verification' && obj.challenge) { + return NextResponse.json({ challenge: obj.challenge }) + } + + return null +} + +export const slackHandler: WebhookProviderHandler = { + handleChallenge(body: unknown) { + return handleSlackChallenge(body) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.event_id) { + return String(obj.event_id) + } + + const event = obj.event as Record | undefined + if (event?.ts && obj.team_id) { + return `${obj.team_id}:${event.ts}` + } + + return null + }, + + formatSuccessResponse() { + return new NextResponse(null, { status: 200 }) + }, + + formatQueueErrorResponse() { + return new NextResponse(null, { status: 200 }) + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const botToken = providerConfig.botToken as string | undefined + const includeFiles = Boolean(providerConfig.includeFiles) + + const rawEvent = b?.event as Record | undefined + + if (!rawEvent) { + logger.warn('Unknown Slack event type', { + type: b?.type, + hasEvent: false, + bodyKeys: Object.keys(b || {}), + }) + } + + const eventType: string = (rawEvent?.type as string) || (b?.type as string) || 'unknown' + const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) + + const item = rawEvent?.item as Record | undefined + const channel: string = isReactionEvent + ? (item?.channel as string) || '' + : (rawEvent?.channel as string) || '' + const messageTs: string = isReactionEvent + ? (item?.ts as string) || '' + : (rawEvent?.ts as string) || (rawEvent?.event_ts as string) || '' + + let text: string = (rawEvent?.text as string) || '' + if (isReactionEvent && channel && messageTs && botToken) { + text = await fetchSlackMessageText(channel, messageTs, botToken) + } + + const rawFiles: unknown[] = (rawEvent?.files as unknown[]) ?? [] + const hasFiles = rawFiles.length > 0 + + let files: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + if (hasFiles && includeFiles && botToken) { + files = await downloadSlackFiles(rawFiles, botToken) + } else if (hasFiles && includeFiles && !botToken) { + logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') + } + + return { + input: { + event: { + event_type: eventType, + channel, + channel_name: '', + user: (rawEvent?.user as string) || '', + user_name: '', + text, + timestamp: messageTs, + thread_ts: (rawEvent?.thread_ts as string) || '', + team_id: (b?.team_id as string) || (rawEvent?.team as string) || '', + event_id: (b?.event_id as string) || '', + reaction: (rawEvent?.reaction as string) || '', + item_user: (rawEvent?.item_user as string) || '', + hasFiles, + files, + }, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts new file mode 100644 index 00000000000..7b9414ab788 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -0,0 +1,28 @@ +import { createLogger } from '@sim/logger' +import type { + EventFilterContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Stripe') + +export const stripeHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Stripe', logger) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.object === 'event') { + return String(obj.id) + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/subscription-utils.ts b/apps/sim/lib/webhooks/providers/subscription-utils.ts new file mode 100644 index 00000000000..17c6ca29514 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/subscription-utils.ts @@ -0,0 +1,39 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProviderSubscriptions') + +export function getProviderConfig(webhook: Record): Record { + return (webhook.providerConfig as Record) || {} +} + +export function getNotificationUrl(webhook: Record): string { + return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` +} + +export async function getCredentialOwner( + credentialId: string, + requestId: string +): Promise<{ userId: string; accountId: string } | null> { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.warn(`[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}`) + return null + } + const [credentialRecord] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + + if (!credentialRecord?.userId) { + logger.warn(`[${requestId}] Credential owner not found for credentialId ${credentialId}`) + return null + } + + return { userId: credentialRecord.userId, accountId: resolved.accountId } +} diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts new file mode 100644 index 00000000000..8511f9b1198 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -0,0 +1,205 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Telegram') + +export const telegramHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId }: AuthContext) { + const userAgent = request.headers.get('user-agent') + if (!userAgent) { + logger.warn( + `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` + ) + } + return null + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const rawMessage = (b?.message || + b?.edited_message || + b?.channel_post || + b?.edited_channel_post) as Record | undefined + + const updateType = b.message + ? 'message' + : b.edited_message + ? 'edited_message' + : b.channel_post + ? 'channel_post' + : b.edited_channel_post + ? 'edited_channel_post' + : 'unknown' + + if (rawMessage) { + const messageType = rawMessage.photo + ? 'photo' + : rawMessage.document + ? 'document' + : rawMessage.audio + ? 'audio' + : rawMessage.video + ? 'video' + : rawMessage.voice + ? 'voice' + : rawMessage.sticker + ? 'sticker' + : rawMessage.location + ? 'location' + : rawMessage.contact + ? 'contact' + : rawMessage.poll + ? 'poll' + : 'text' + + const from = rawMessage.from as Record | undefined + return { + input: { + message: { + id: rawMessage.message_id, + text: rawMessage.text, + date: rawMessage.date, + messageType, + raw: rawMessage, + }, + sender: from + ? { + id: from.id, + username: from.username, + firstName: from.first_name, + lastName: from.last_name, + languageCode: from.language_code, + isBot: from.is_bot, + } + : null, + updateId: b.update_id, + updateType, + }, + } + } + + logger.warn('Unknown Telegram update type', { + updateId: b.update_id, + bodyKeys: Object.keys(b || {}), + }) + + return { + input: { + updateId: b.update_id, + updateType, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn(`[${ctx.requestId}] Missing botToken for Telegram webhook ${ctx.webhook.id}`) + throw new Error( + 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' + ) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` + + try { + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TelegramBot/1.0', + }, + body: JSON.stringify({ url: notificationUrl }), + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to create Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + + let userFriendlyMessage = 'Failed to create Telegram webhook' + if (telegramResponse.status === 401) { + userFriendlyMessage = + 'Invalid bot token. Please verify that the bot token is correct and try again.' + } else if (responseBody.description) { + userFriendlyMessage = `Telegram error: ${responseBody.description}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${ctx.requestId}] Successfully created Telegram webhook for webhook ${ctx.webhook.id}` + ) + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Bot token') || error.message.includes('Telegram error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Telegram webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn( + `[${ctx.requestId}] Missing botToken for Telegram webhook deletion ${ctx.webhook.id}` + ) + return + } + + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}` + ) + } + } catch (error) { + logger.error( + `[${ctx.requestId}] Error deleting Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts new file mode 100644 index 00000000000..be0417ef71d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -0,0 +1,214 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' + +const logger = createLogger('WebhookProvider:TwilioVoice') + +async function validateTwilioSignature( + authToken: string, + signature: string, + url: string, + params: Record +): Promise { + try { + if (!authToken || !signature || !url) { + logger.warn('Twilio signature validation missing required fields', { + hasAuthToken: !!authToken, + hasSignature: !!signature, + hasUrl: !!url, + }) + return false + } + const sortedKeys = Object.keys(params).sort() + let data = url + for (const key of sortedKeys) { + data += key + params[key] + } + logger.debug('Twilio signature validation string built', { + url, + sortedKeys, + dataLength: data.length, + }) + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ) + const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) + const signatureArray = Array.from(new Uint8Array(signatureBytes)) + const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) + logger.debug('Twilio signature comparison', { + computedSignature: `${signatureBase64.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: signatureBase64.length, + providedLength: signature.length, + match: signatureBase64 === signature, + }) + return safeCompare(signatureBase64, signature) + } catch (error) { + logger.error('Error validating Twilio signature:', error) + return false + } +} + +function getExternalUrl(request: Request): string { + const proto = request.headers.get('x-forwarded-proto') || 'https' + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') + + if (host) { + const url = new URL(request.url) + const reconstructed = `${proto}://${host}${url.pathname}${url.search}` + return reconstructed + } + + return request.url +} + +export const twilioVoiceHandler: WebhookProviderHandler = { + async verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const authToken = providerConfig.authToken as string | undefined + + if (authToken) { + const signature = request.headers.get('x-twilio-signature') + + if (!signature) { + logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Twilio signature', { + status: 401, + }) + } + + let params: Record = {} + try { + if (typeof rawBody === 'string') { + const urlParams = new URLSearchParams(rawBody) + params = Object.fromEntries(urlParams.entries()) + } + } catch (error) { + logger.error( + `[${requestId}] Error parsing Twilio webhook body for signature validation:`, + error + ) + return new NextResponse('Bad Request - Invalid body format', { + status: 400, + }) + } + + const fullUrl = getExternalUrl(request) + const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { + url: fullUrl, + signatureLength: signature.length, + paramsCount: Object.keys(params).length, + authTokenLength: authToken.length, + }) + return new NextResponse('Unauthorized - Invalid Twilio signature', { + status: 401, + }) + } + } + + return null + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, + + formatSuccessResponse(providerConfig: Record) { + const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() + + if (twimlResponse && twimlResponse.length > 0) { + const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) + return new NextResponse(convertedTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + } + + const defaultTwiml = ` + + Your call is being processed. + +` + + return new NextResponse(defaultTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + callSid: b.CallSid, + accountSid: b.AccountSid, + from: b.From, + to: b.To, + callStatus: b.CallStatus, + direction: b.Direction, + apiVersion: b.ApiVersion, + callerName: b.CallerName, + forwardedFrom: b.ForwardedFrom, + digits: b.Digits, + speechResult: b.SpeechResult, + recordingUrl: b.RecordingUrl, + recordingSid: b.RecordingSid, + called: b.Called, + caller: b.Caller, + toCity: b.ToCity, + toState: b.ToState, + toZip: b.ToZip, + toCountry: b.ToCountry, + fromCity: b.FromCity, + fromState: b.FromState, + fromZip: b.FromZip, + fromCountry: b.FromCountry, + calledCity: b.CalledCity, + calledState: b.CalledState, + calledZip: b.CalledZip, + calledCountry: b.CalledCountry, + callerCity: b.CallerCity, + callerState: b.CallerState, + callerZip: b.CallerZip, + callerCountry: b.CallerCountry, + callToken: b.CallToken, + raw: JSON.stringify(b), + }, + } + }, + + formatQueueErrorResponse() { + const errorTwiml = ` + + We're sorry, but an error occurred processing your call. Please try again later. + +` + + return new NextResponse(errorTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml', + }, + }) + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio.ts b/apps/sim/lib/webhooks/providers/twilio.ts new file mode 100644 index 00000000000..3ba33decc11 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio.ts @@ -0,0 +1,8 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +export const twilioHandler: WebhookProviderHandler = { + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, +} diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts new file mode 100644 index 00000000000..068c72d9cd4 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -0,0 +1,213 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Typeform') + +function validateTypeformSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Typeform signature:', error) + return false + } +} + +export const typeformHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const formResponse = (b?.form_response || {}) as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const includeDefinition = providerConfig.includeDefinition === true + return { + input: { + event_id: b?.event_id || '', + event_type: b?.event_type || 'form_response', + form_id: formResponse.form_id || '', + token: formResponse.token || '', + submitted_at: formResponse.submitted_at || '', + landed_at: formResponse.landed_at || '', + calculated: formResponse.calculated || {}, + variables: formResponse.variables || [], + hidden: formResponse.hidden || {}, + answers: formResponse.answers || [], + ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), + ending: formResponse.ending || {}, + raw: b, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'secret', + headerName: 'Typeform-Signature', + validateFn: validateTypeformSignature, + providerLabel: 'Typeform', + }), + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + const secret = config.secret as string | undefined + + if (!formId) { + logger.warn(`[${ctx.requestId}] Missing formId for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' + ) + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' + ) + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const notificationUrl = getNotificationUrl(ctx.webhook) + + try { + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const requestBody: Record = { + url: notificationUrl, + enabled: true, + verify_ssl: true, + event_types: { + form_response: true, + }, + } + + if (secret) { + requestBody.secret = secret + } + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!typeformResponse.ok) { + const responseBody = await typeformResponse.json().catch(() => ({})) + const errorMessage = + (responseBody as Record).description || + (responseBody as Record).message || + 'Unknown error' + + logger.error(`[${ctx.requestId}] Typeform API error: ${errorMessage}`, { + status: typeformResponse.status, + response: responseBody, + }) + + let userFriendlyMessage = 'Failed to create Typeform webhook' + if (typeformResponse.status === 401) { + userFriendlyMessage = + 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' + } else if (typeformResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' + } else if (typeformResponse.status === 404) { + userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' + } else if ( + (responseBody as Record).description || + (responseBody as Record).message + ) { + userFriendlyMessage = `Typeform error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await typeformResponse.json() + logger.info( + `[${ctx.requestId}] Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`, + { webhookId: (responseBody as Record).id } + ) + + if (!webhookTag && tag) { + return { providerConfigUpdates: { webhookTag: tag } } + } + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Form ID') || + error.message.includes('Personal Access Token') || + error.message.includes('Typeform error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Typeform webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Typeform webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + + if (!formId || !apiKey) { + logger.warn( + `[${ctx.requestId}] Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!typeformResponse.ok && typeformResponse.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted Typeform webhook with tag ${tag}`) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Typeform webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts new file mode 100644 index 00000000000..34587ce7a42 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -0,0 +1,143 @@ +import type { NextRequest, NextResponse } from 'next/server' + +/** Context for signature/token verification. */ +export interface AuthContext { + webhook: Record + workflow: Record + request: NextRequest + rawBody: string + requestId: string + providerConfig: Record +} + +/** Context for event matching against trigger configuration. */ +export interface EventMatchContext { + webhook: Record + workflow: Record + body: unknown + request: NextRequest + requestId: string + providerConfig: Record +} + +/** Context for event filtering and header enrichment. */ +export interface EventFilterContext { + webhook: Record + body: unknown + requestId: string + providerConfig: Record +} + +/** Context for custom input preparation during execution. */ +export interface FormatInputContext { + webhook: Record + workflow: { id: string; userId: string } + body: unknown + headers: Record + requestId: string +} + +/** Result of custom input preparation. */ +export interface FormatInputResult { + input: unknown + skip?: { message: string } +} + +/** Context for provider-specific file processing before execution. */ +export interface ProcessFilesContext { + input: Record + blocks: Record + blockId: string + workspaceId: string + workflowId: string + executionId: string + requestId: string + userId: string +} + +/** Context for creating an external webhook subscription during deployment. */ +export interface SubscriptionContext { + webhook: Record + workflow: Record + userId: string + requestId: string + request: NextRequest +} + +/** Result of creating an external webhook subscription. */ +export interface SubscriptionResult { + /** Fields to merge into providerConfig (externalId, webhookSecret, etc.) */ + providerConfigUpdates?: Record +} + +/** Context for deleting an external webhook subscription during undeployment. */ +export interface DeleteSubscriptionContext { + webhook: Record + workflow: Record + requestId: string +} + +/** Context for configuring polling after webhook creation. */ +export interface PollingConfigContext { + webhook: Record + requestId: string +} + +/** + * Strategy interface for provider-specific webhook behavior. + * Each provider implements only the methods it needs — all methods are optional. + */ +export interface WebhookProviderHandler { + /** Verify signature/auth. Return NextResponse(401/403) on failure, null on success. */ + verifyAuth?(ctx: AuthContext): Promise | NextResponse | null + + /** Handle reachability/verification probes after webhook lookup. */ + handleReachabilityTest?(body: unknown, requestId: string): NextResponse | null + + /** Format error responses (some providers need special formats). */ + formatErrorResponse?(error: string, status: number): NextResponse + + /** Return true to skip this event (filtering by event type, collection, etc.). */ + shouldSkipEvent?(ctx: EventFilterContext): boolean + + /** Return true if event matches, false or NextResponse to skip with a custom response. */ + matchEvent?(ctx: EventMatchContext): Promise | boolean | NextResponse + + /** Add provider-specific headers (idempotency keys, notification IDs, etc.). */ + enrichHeaders?(ctx: EventFilterContext, headers: Record): void + + /** Extract unique identifier for idempotency dedup. */ + extractIdempotencyId?(body: unknown): string | null + + /** Custom success response after queuing. Return null for default `{message: "Webhook processed"}`. */ + formatSuccessResponse?(providerConfig: Record): NextResponse | null + + /** Custom error response when queuing fails. Return null for default 500. */ + formatQueueErrorResponse?(): NextResponse | null + + /** Custom input preparation. Replaces the standard `formatWebhookInput` call when defined. */ + formatInput?(ctx: FormatInputContext): Promise + + /** Called when standard `formatWebhookInput` returns null. Return skip message or null to proceed. */ + handleEmptyInput?(requestId: string): { message: string } | null + + /** Post-process input to handle file uploads before execution. */ + processInputFiles?(ctx: ProcessFilesContext): Promise + + /** Create an external webhook subscription (e.g., register with Telegram, Airtable, etc.). */ + createSubscription?(ctx: SubscriptionContext): Promise + + /** Delete an external webhook subscription during cleanup. Errors should not throw. */ + deleteSubscription?(ctx: DeleteSubscriptionContext): Promise + + /** Configure polling after webhook creation (gmail, outlook, rss, imap). */ + configurePolling?(ctx: PollingConfigContext): Promise + + /** Handle verification challenges before webhook lookup (Slack url_verification, WhatsApp hub.verify_token, Teams validationToken). */ + handleChallenge?( + body: unknown, + request: NextRequest, + requestId: string, + path: string + ): Promise | NextResponse | null +} diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts new file mode 100644 index 00000000000..f2a56047081 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -0,0 +1,102 @@ +import type { Logger } from '@sim/logger' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProviderAuth') + +interface HmacVerifierOptions { + configKey: string + headerName: string + validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise + providerLabel: string +} + +/** + * Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers. + * Covers the common pattern: get secret → check header → validate signature → return 401 or null. + */ +export function createHmacVerifier({ + configKey, + headerName, + validateFn, + providerLabel, +}: HmacVerifierOptions) { + return async ({ + request, + rawBody, + requestId, + providerConfig, + }: AuthContext): Promise => { + const secret = providerConfig[configKey] as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get(headerName) + if (!signature) { + logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`) + return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 }) + } + + const isValid = await validateFn(secret, signature, rawBody) + if (!isValid) { + logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 }) + } + + return null + } +} + +/** + * Verify a bearer token or custom header token using timing-safe comparison. + * Used by generic webhooks, Google Forms, and the default handler. + */ +export function verifyTokenAuth( + request: Request, + expectedToken: string, + secretHeaderName?: string +): boolean { + if (secretHeaderName) { + const headerValue = request.headers.get(secretHeaderName.toLowerCase()) + return !!headerValue && safeCompare(headerValue, expectedToken) + } + + const authHeader = request.headers.get('authorization') + if (authHeader?.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7) + return safeCompare(token, expectedToken) + } + + return false +} + +/** + * Skip events whose `body.type` is not in the `providerConfig.eventTypes` allowlist. + * Shared by providers that use a simple event-type filter (Stripe, Grain, etc.). + */ +export function skipByEventTypes( + { webhook, body, requestId, providerConfig }: EventFilterContext, + providerLabel: string, + eventLogger: Logger +): boolean { + const eventTypes = providerConfig.eventTypes + if (!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0) { + return false + } + + const eventType = (body as Record)?.type as string | undefined + if (eventType && !eventTypes.includes(eventType)) { + eventLogger.info( + `[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping` + ) + return true + } + + return false +} diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts new file mode 100644 index 00000000000..4596e8381fc --- /dev/null +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -0,0 +1,307 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Webflow') + +export const webflowHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { siteId, triggerId, collectionId, formName, credentialId } = config as { + siteId?: string + triggerId?: string + collectionId?: string + formName?: string + credentialId?: string + } + + if (!siteId) { + logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Site ID is required to create Webflow webhook') + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + throw new Error(siteIdValidation.error) + } + + if (!triggerId) { + logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Trigger type is required to create Webflow webhook') + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'webflow') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` + ) + throw new Error( + 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const triggerTypeMap: Record = { + webflow_collection_item_created: 'collection_item_created', + webflow_collection_item_changed: 'collection_item_changed', + webflow_collection_item_deleted: 'collection_item_deleted', + webflow_form_submission: 'form_submission', + } + + const webflowTriggerType = triggerTypeMap[triggerId] + if (!webflowTriggerType) { + logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Invalid Webflow trigger type: ${triggerId}`) + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` + + const requestBody: Record = { + triggerType: webflowTriggerType, + url: notificationUrl, + } + + if (formName && webflowTriggerType === 'form_submission') { + requestBody.filter = { + name: formName, + } + } + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await webflowResponse.json() + + if (!webflowResponse.ok || responseBody.error) { + const errorMessage = + responseBody.message || responseBody.error || 'Unknown Webflow API error' + logger.error( + `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`, + { message: errorMessage, response: responseBody } + ) + throw new Error(errorMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, + { + webflowWebhookId: responseBody.id || responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const siteId = config.siteId as string | undefined + const externalId = config.externalId as string | undefined + + if (!siteId) { + logger.warn( + `[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { + webhookId: webhookRecord.id, + siteId: siteId.substring(0, 30), + }) + return + } + + const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: externalId.substring(0, 30), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + }) + + if (!webflowResponse.ok && webflowResponse.status !== 404) { + const responseBody = await webflowResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'webflow_form_submission') { + return { + input: { + siteId: b?.siteId || '', + formId: b?.formId || '', + name: b?.name || '', + id: b?.id || '', + submittedAt: b?.submittedAt || '', + data: b?.data || {}, + schema: b?.schema || {}, + formElementId: b?.formElementId || '', + }, + } + } + const { _cid, _id, ...itemFields } = b || ({} as Record) + return { + input: { + siteId: b?.siteId || '', + collectionId: (_cid || b?.collectionId || '') as string, + payload: { + id: (_id || '') as string, + cmsLocaleId: (itemFields as Record)?.cmsLocaleId || '', + lastPublished: + (itemFields as Record)?.lastPublished || + (itemFields as Record)?.['last-published'] || + '', + lastUpdated: + (itemFields as Record)?.lastUpdated || + (itemFields as Record)?.['last-updated'] || + '', + createdOn: + (itemFields as Record)?.createdOn || + (itemFields as Record)?.['created-on'] || + '', + isArchived: + (itemFields as Record)?.isArchived || + (itemFields as Record)?._archived || + false, + isDraft: + (itemFields as Record)?.isDraft || + (itemFields as Record)?._draft || + false, + fieldData: itemFields, + }, + }, + } + }, + + shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) { + const configuredCollectionId = providerConfig.collectionId as string | undefined + if (configuredCollectionId) { + const obj = body as Record + const payload = obj.payload as Record | undefined + const payloadCollectionId = (payload?.collectionId ?? obj.collectionId) as string | undefined + + if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { + logger.info( + `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping` + ) + return true + } + } + return false + }, +} diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts new file mode 100644 index 00000000000..5f0116b2a1b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -0,0 +1,118 @@ +import { db, workflowDeploymentVersion } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull, or } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:WhatsApp') + +/** + * Handle WhatsApp verification requests + */ +export async function handleWhatsAppVerification( + requestId: string, + path: string, + mode: string | null, + token: string | null, + challenge: string | null +): Promise { + if (mode && token && challenge) { + logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) + + if (mode !== 'subscribe') { + logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) + return new NextResponse('Invalid mode', { status: 400 }) + } + + const webhooks = await db + .select({ webhook }) + .from(webhook) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, webhook.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, 'whatsapp'), + eq(webhook.isActive, true), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + for (const row of webhooks) { + const wh = row.webhook + const providerConfig = (wh.providerConfig as Record) || {} + const verificationToken = providerConfig.verificationToken + + if (!verificationToken) { + continue + } + + if (token === verificationToken) { + logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) + return new NextResponse(challenge, { + status: 200, + headers: { + 'Content-Type': 'text/plain', + }, + }) + } + } + + logger.warn(`[${requestId}] No matching WhatsApp verification token found`) + return new NextResponse('Verification failed', { status: 403 }) + } + + return null +} + +export const whatsappHandler: WebhookProviderHandler = { + async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const mode = url.searchParams.get('hub.mode') + const token = url.searchParams.get('hub.verify_token') + const challenge = url.searchParams.get('hub.challenge') + return handleWhatsAppVerification(requestId, path, mode, token, challenge) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const entry = b?.entry as Array> | undefined + const changes = entry?.[0]?.changes as Array> | undefined + const data = changes?.[0]?.value as Record | undefined + const messages = (data?.messages as Array>) || [] + + if (messages.length > 0) { + const message = messages[0] + const metadata = data?.metadata as Record | undefined + const text = message.text as Record | undefined + return { + input: { + messageId: message.id, + from: message.from, + phoneNumberId: metadata?.phone_number_id, + text: text?.body, + timestamp: message.timestamp, + raw: JSON.stringify(message), + }, + } + } + return { input: null } + }, + + handleEmptyInput(requestId: string) { + logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) + return { message: 'No messages in WhatsApp payload' } + }, +} diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 49c29227ff4..4e0d3d168be 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,2214 +1,14 @@ -import crypto from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' -import { account, webhook, workflow } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' -import { - type SecureFetchResponse, - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateShortId } from '@/lib/core/utils/uuid' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { - getCredentialsForCredentialSet, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -const logger = createLogger('WebhookUtils') - -/** - * Handle WhatsApp verification requests - */ -export async function handleWhatsAppVerification( - requestId: string, - path: string, - mode: string | null, - token: string | null, - challenge: string | null -): Promise { - if (mode && token && challenge) { - logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) - - if (mode !== 'subscribe') { - logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) - return new NextResponse('Invalid mode', { status: 400 }) - } - - const webhooks = await db - .select({ webhook }) - .from(webhook) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, webhook.workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.provider, 'whatsapp'), - eq(webhook.isActive, true), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - - for (const row of webhooks) { - const wh = row.webhook - const providerConfig = (wh.providerConfig as Record) || {} - const verificationToken = providerConfig.verificationToken - - if (!verificationToken) { - continue - } - - if (token === verificationToken) { - logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) - return new NextResponse(challenge, { - status: 200, - headers: { - 'Content-Type': 'text/plain', - }, - }) - } - } - - logger.warn(`[${requestId}] No matching WhatsApp verification token found`) - return new NextResponse('Verification failed', { status: 403 }) - } - - return null -} - -/** - * Handle Slack verification challenges - */ -export function handleSlackChallenge(body: any): NextResponse | null { - if (body.type === 'url_verification' && body.challenge) { - return NextResponse.json({ challenge: body.challenge }) - } - - return null -} - -/** - * Fetches a URL with DNS pinning to prevent DNS rebinding attacks - * @param url - The URL to fetch - * @param accessToken - Authorization token (optional for pre-signed URLs) - * @param requestId - Request ID for logging - * @returns The fetch Response or null if validation fails - */ -async function fetchWithDNSPinning( - url: string, - accessToken: string, - requestId: string -): Promise { - try { - const urlValidation = await validateUrlWithDNS(url, 'contentUrl') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { - url, - }) - return null - } - - const headers: Record = {} - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - headers, - }) - - return response - } catch (error) { - logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { - error: error instanceof Error ? error.message : String(error), - url: sanitizeUrlForLog(url), - }) - return null - } -} - -/** - * Format Microsoft Teams Graph change notification - */ -async function formatTeamsGraphNotification( - body: any, - foundWebhook: any, - foundWorkflow: any, - request: NextRequest -): Promise { - const notification = body.value?.[0] - if (!notification) { - logger.warn('Received empty Teams notification body') - return null - } - const changeType = notification.changeType || 'created' - const resource = notification.resource || '' - const subscriptionId = notification.subscriptionId || '' - - let chatId: string | null = null - let messageId: string | null = null - - const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) - if (fullMatch) { - chatId = fullMatch[1] - messageId = fullMatch[2] - } - - if (!chatId || !messageId) { - const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (quotedMatch) { - chatId = quotedMatch[1] - messageId = quotedMatch[2] - } - } - - if (!chatId || !messageId) { - const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) - const rdId = body?.value?.[0]?.resourceData?.id - if (collectionMatch && rdId) { - chatId = collectionMatch[1] - messageId = rdId - } - } - - if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { - const odataId = String(body.value[0].resourceData['@odata.id']) - const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (odataMatch) { - chatId = odataMatch[1] - messageId = odataMatch[2] - } - } - - if (!chatId || !messageId) { - logger.warn('Could not resolve chatId/messageId from Teams notification', { - resource, - hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), - valueLength: Array.isArray(body?.value) ? body.value.length : 0, - keys: Object.keys(body || {}), - }) - return { - from: null, - message: { raw: body }, - activity: body, - conversation: null, - } - } - const resolvedChatId = chatId as string - const resolvedMessageId = messageId as string - const providerConfig = (foundWebhook?.providerConfig as Record) || {} - const credentialId = providerConfig.credentialId - const includeAttachments = providerConfig.includeAttachments !== false - - let message: any = null - const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = - [] - let accessToken: string | null = null - - if (!credentialId) { - logger.error('Missing credentialId for Teams chat subscription', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - webhookId: foundWebhook?.id, - blockId: foundWebhook?.blockId, - providerConfig, - }) - } else { - try { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - logger.error('Teams credential could not be resolved', { credentialId }) - } else { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) - } else { - const effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - effectiveUserId, - 'teams-graph-notification' - ) - } - } - - if (accessToken) { - const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` - const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) - if (res.ok) { - message = await res.json() - - if (includeAttachments && message?.attachments?.length > 0) { - const attachments = Array.isArray(message?.attachments) ? message.attachments : [] - for (const att of attachments) { - try { - const contentUrl = - typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined - const contentTypeHint = - typeof att?.contentType === 'string' ? (att.contentType as string) : undefined - let attachmentName = (att?.name as string) || 'teams-attachment' - - if (!contentUrl) continue - - let buffer: Buffer | null = null - let mimeType = 'application/octet-stream' - - if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { - try { - const directRes = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment' - ) - - if (directRes?.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else if (directRes) { - const encodedUrl = Buffer.from(contentUrl) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` - const graphRes = await fetch(graphUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (graphRes.ok) { - const arrayBuffer = await graphRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - graphRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } - } catch { - continue - } - } else if ( - contentUrl.includes('1drv.ms') || - contentUrl.includes('onedrive.live.com') || - contentUrl.includes('onedrive.com') || - contentUrl.includes('my.microsoftpersonalcontent.com') - ) { - try { - let shareToken: string | null = null - - if (contentUrl.includes('1drv.ms')) { - const urlParts = contentUrl.split('/').pop() - if (urlParts) shareToken = urlParts - } else if (contentUrl.includes('resid=')) { - const urlParams = new URL(contentUrl).searchParams - const resId = urlParams.get('resid') - if (resId) shareToken = resId - } - - if (!shareToken) { - const base64Url = Buffer.from(contentUrl, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } else if (!shareToken.startsWith('u!')) { - const base64Url = Buffer.from(shareToken, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } - - const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` - const metadataRes = await fetch(metadataUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!metadataRes.ok) { - const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` - const directRes = await fetch(directUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } else { - const metadata = await metadataRes.json() - const downloadUrl = metadata['@microsoft.graph.downloadUrl'] - - if (downloadUrl) { - const downloadRes = await fetchWithDNSPinning( - downloadUrl, - '', // downloadUrl is a pre-signed URL, no auth needed - 'teams-onedrive-download' - ) - - if (downloadRes?.ok) { - const arrayBuffer = await downloadRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - downloadRes.headers.get('content-type') || - metadata.file?.mimeType || - contentTypeHint || - 'application/octet-stream' - - if (metadata.name && metadata.name !== attachmentName) { - attachmentName = metadata.name - } - } else { - continue - } - } else { - continue - } - } - } catch { - continue - } - } else { - try { - const ares = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment-generic' - ) - if (ares?.ok) { - const arrayBuffer = await ares.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - ares.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } - } catch { - continue - } - } - - if (!buffer) continue - - const size = buffer.length - - // Store raw attachment (will be uploaded to execution storage later) - rawAttachments.push({ - name: attachmentName, - data: buffer, - contentType: mimeType, - size, - }) - } catch {} - } - } - } - } - } catch (error) { - logger.error('Failed to fetch Teams message', { - error, - chatId: resolvedChatId, - messageId: resolvedMessageId, - }) - } - } - - if (!message) { - logger.warn('No message data available for Teams notification', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - hasCredential: !!credentialId, - }) - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: '', - text: '', - created_at: '', - attachments: [], - } - } - - const messageText = message.body?.content || '' - const from = message.from?.user || {} - const createdAt = message.createdDateTime || '' - - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: from.displayName || '', - text: messageText, - created_at: createdAt, - attachments: rawAttachments, - } -} - -export async function validateTwilioSignature( - authToken: string, - signature: string, - url: string, - params: Record -): Promise { - try { - if (!authToken || !signature || !url) { - logger.warn('Twilio signature validation missing required fields', { - hasAuthToken: !!authToken, - hasSignature: !!signature, - hasUrl: !!url, - }) - return false - } - - const sortedKeys = Object.keys(params).sort() - let data = url - for (const key of sortedKeys) { - data += key + params[key] - } - - logger.debug('Twilio signature validation string built', { - url, - sortedKeys, - dataLength: data.length, - }) - - const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(authToken), - { name: 'HMAC', hash: 'SHA-1' }, - false, - ['sign'] - ) - - const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) - - const signatureArray = Array.from(new Uint8Array(signatureBytes)) - const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) - - logger.debug('Twilio signature comparison', { - computedSignature: `${signatureBase64.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: signatureBase64.length, - providedLength: signature.length, - match: signatureBase64 === signature, - }) - - return safeCompare(signatureBase64, signature) - } catch (error) { - logger.error('Error validating Twilio signature:', error) - return false - } -} - -const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB -const SLACK_MAX_FILES = 15 - -/** - * Resolves the full file object from the Slack API when the event payload - * only contains a partial file (e.g. missing url_private due to file_access restrictions). - * @see https://docs.slack.dev/reference/methods/files.info - */ -async function resolveSlackFileInfo( - fileId: string, - botToken: string -): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { - try { - const response = await fetch( - `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, - { - headers: { Authorization: `Bearer ${botToken}` }, - } - ) - - const data = (await response.json()) as { - ok: boolean - error?: string - file?: Record - } - - if (!data.ok || !data.file) { - logger.warn('Slack files.info failed', { fileId, error: data.error }) - return null - } - - return { - url_private: data.file.url_private, - name: data.file.name, - mimetype: data.file.mimetype, - size: data.file.size, - } - } catch (error) { - logger.error('Error calling Slack files.info', { - fileId, - error: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -/** - * Downloads file attachments from Slack using the bot token. - * Returns files in the format expected by WebhookAttachmentProcessor: - * { name, data (base64 string), mimeType, size } - * - * When the event payload contains partial file objects (missing url_private), - * falls back to the Slack files.info API to resolve the full file metadata. - * - * Security: - * - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF - * - Enforces per-file size limit and max file count - */ -async function downloadSlackFiles( - rawFiles: any[], - botToken: string -): Promise> { - const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) - const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] - - for (const file of filesToProcess) { - let urlPrivate = file.url_private as string | undefined - let fileName = file.name as string | undefined - let fileMimeType = file.mimetype as string | undefined - let fileSize = file.size as number | undefined - - // If url_private is missing, resolve via files.info API - if (!urlPrivate && file.id) { - const resolved = await resolveSlackFileInfo(file.id, botToken) - if (resolved?.url_private) { - urlPrivate = resolved.url_private - fileName = fileName || resolved.name - fileMimeType = fileMimeType || resolved.mimetype - fileSize = fileSize ?? resolved.size - } - } - - if (!urlPrivate) { - logger.warn('Slack file has no url_private and could not be resolved, skipping', { - fileId: file.id, - }) - continue - } - - // Skip files that exceed the size limit - const reportedSize = Number(fileSize) || 0 - if (reportedSize > SLACK_MAX_FILE_SIZE) { - logger.warn('Slack file exceeds size limit, skipping', { - fileId: file.id, - size: reportedSize, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - try { - const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') - if (!urlValidation.isValid) { - logger.warn('Slack file url_private failed DNS validation, skipping', { - fileId: file.id, - error: urlValidation.error, - }) - continue - } - - const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - if (!response.ok) { - logger.warn('Failed to download Slack file, skipping', { - fileId: file.id, - status: response.status, - }) - continue - } - - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Verify the actual downloaded size doesn't exceed our limit - if (buffer.length > SLACK_MAX_FILE_SIZE) { - logger.warn('Downloaded Slack file exceeds size limit, skipping', { - fileId: file.id, - actualSize: buffer.length, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - downloaded.push({ - name: fileName || 'download', - data: buffer.toString('base64'), - mimeType: fileMimeType || 'application/octet-stream', - size: buffer.length, - }) - } catch (error) { - logger.error('Error downloading Slack file, skipping', { - fileId: file.id, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - return downloaded -} - -const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) - -/** - * Fetches the text of a reacted-to message from Slack using the reactions.get API. - * Unlike conversations.history, reactions.get works for both top-level messages and - * thread replies, since it looks up the item directly by channel + timestamp. - * Requires the bot token to have the reactions:read scope. - */ -async function fetchSlackMessageText( - channel: string, - messageTs: string, - botToken: string -): Promise { - try { - const params = new URLSearchParams({ - channel, - timestamp: messageTs, - }) - const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - const data = (await response.json()) as { - ok: boolean - error?: string - type?: string - message?: { text?: string } - } - - if (!data.ok) { - logger.warn('Slack reactions.get failed — message text unavailable', { - channel, - messageTs, - error: data.error, - }) - return '' - } - - return data.message?.text ?? '' - } catch (error) { - logger.warn('Error fetching Slack message text', { - channel, - messageTs, - error: error instanceof Error ? error.message : String(error), - }) - return '' - } -} - -/** - * Format webhook input based on provider - */ -export async function formatWebhookInput( - foundWebhook: any, - foundWorkflow: any, - body: any, - request: NextRequest -): Promise { - if (foundWebhook.provider === 'whatsapp') { - const data = body?.entry?.[0]?.changes?.[0]?.value - const messages = data?.messages || [] - - if (messages.length > 0) { - const message = messages[0] - return { - messageId: message.id, - from: message.from, - phoneNumberId: data.metadata?.phone_number_id, - text: message.text?.body, - timestamp: message.timestamp, - raw: JSON.stringify(message), - } - } - return null - } - - if (foundWebhook.provider === 'telegram') { - const rawMessage = - body?.message || body?.edited_message || body?.channel_post || body?.edited_channel_post - - const updateType = body.message - ? 'message' - : body.edited_message - ? 'edited_message' - : body.channel_post - ? 'channel_post' - : body.edited_channel_post - ? 'edited_channel_post' - : 'unknown' - - if (rawMessage) { - const messageType = rawMessage.photo - ? 'photo' - : rawMessage.document - ? 'document' - : rawMessage.audio - ? 'audio' - : rawMessage.video - ? 'video' - : rawMessage.voice - ? 'voice' - : rawMessage.sticker - ? 'sticker' - : rawMessage.location - ? 'location' - : rawMessage.contact - ? 'contact' - : rawMessage.poll - ? 'poll' - : 'text' - - return { - message: { - id: rawMessage.message_id, - text: rawMessage.text, - date: rawMessage.date, - messageType, - raw: rawMessage, - }, - sender: rawMessage.from - ? { - id: rawMessage.from.id, - username: rawMessage.from.username, - firstName: rawMessage.from.first_name, - lastName: rawMessage.from.last_name, - languageCode: rawMessage.from.language_code, - isBot: rawMessage.from.is_bot, - } - : null, - updateId: body.update_id, - updateType, - } - } - - logger.warn('Unknown Telegram update type', { - updateId: body.update_id, - bodyKeys: Object.keys(body || {}), - }) - - return { - updateId: body.update_id, - updateType, - } - } - - if (foundWebhook.provider === 'twilio_voice') { - return { - callSid: body.CallSid, - accountSid: body.AccountSid, - from: body.From, - to: body.To, - callStatus: body.CallStatus, - direction: body.Direction, - apiVersion: body.ApiVersion, - callerName: body.CallerName, - forwardedFrom: body.ForwardedFrom, - digits: body.Digits, - speechResult: body.SpeechResult, - recordingUrl: body.RecordingUrl, - recordingSid: body.RecordingSid, - called: body.Called, - caller: body.Caller, - toCity: body.ToCity, - toState: body.ToState, - toZip: body.ToZip, - toCountry: body.ToCountry, - fromCity: body.FromCity, - fromState: body.FromState, - fromZip: body.FromZip, - fromCountry: body.FromCountry, - calledCity: body.CalledCity, - calledState: body.CalledState, - calledZip: body.CalledZip, - calledCountry: body.CalledCountry, - callerCity: body.CallerCity, - callerState: body.CallerState, - callerZip: body.CallerZip, - callerCountry: body.CallerCountry, - callToken: body.CallToken, - raw: JSON.stringify(body), - } - } - - if (foundWebhook.provider === 'gmail') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'outlook') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'rss') { - if (body && typeof body === 'object' && 'item' in body) { - return { - title: body.title, - link: body.link, - pubDate: body.pubDate, - item: body.item, - feed: body.feed, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'imap') { - if (body && typeof body === 'object' && 'email' in body) { - return { - messageId: body.messageId, - subject: body.subject, - from: body.from, - to: body.to, - cc: body.cc, - date: body.date, - bodyText: body.bodyText, - bodyHtml: body.bodyHtml, - mailbox: body.mailbox, - hasAttachments: body.hasAttachments, - attachments: body.attachments, - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'hubspot') { - const events = Array.isArray(body) ? body : [body] - const event = events[0] - - if (!event) { - logger.warn('HubSpot webhook received with empty payload') - return null - } - - logger.info('Formatting HubSpot webhook input', { - subscriptionType: event.subscriptionType, - objectId: event.objectId, - portalId: event.portalId, - }) - - return { - payload: body, - provider: 'hubspot', - providerConfig: foundWebhook.providerConfig, - } - } - - if (foundWebhook.provider === 'microsoft-teams') { - if (body?.value && Array.isArray(body.value) && body.value.length > 0) { - return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) - } - - const messageText = body?.text || '' - const messageId = body?.id || '' - const timestamp = body?.timestamp || body?.localTimestamp || '' - const from = body?.from || {} - const conversation = body?.conversation || {} - - const messageObj = { - raw: { - attachments: body?.attachments || [], - channelData: body?.channelData || {}, - conversation: body?.conversation || {}, - text: messageText, - messageType: body?.type || 'message', - channelId: body?.channelId || '', - timestamp, - }, - } - - const fromObj = { - id: from.id || '', - name: from.name || '', - aadObjectId: from.aadObjectId || '', - } - - const conversationObj = { - id: conversation.id || '', - name: conversation.name || '', - isGroup: conversation.isGroup || false, - tenantId: conversation.tenantId || '', - aadObjectId: conversation.aadObjectId || '', - conversationType: conversation.conversationType || '', - } - - const activityObj = body || {} - - return { - from: fromObj, - message: messageObj, - activity: activityObj, - conversation: conversationObj, - } - } - - if (foundWebhook.provider === 'slack') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const botToken = providerConfig.botToken as string | undefined - const includeFiles = Boolean(providerConfig.includeFiles) - - const rawEvent = body?.event - - if (!rawEvent) { - logger.warn('Unknown Slack event type', { - type: body?.type, - hasEvent: false, - bodyKeys: Object.keys(body || {}), - }) - } - - const eventType: string = rawEvent?.type || body?.type || 'unknown' - const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) - - // Reaction events nest channel/ts inside event.item - const channel: string = isReactionEvent - ? rawEvent?.item?.channel || '' - : rawEvent?.channel || '' - const messageTs: string = isReactionEvent - ? rawEvent?.item?.ts || '' - : rawEvent?.ts || rawEvent?.event_ts || '' - - // For reaction events, attempt to fetch the original message text - let text: string = rawEvent?.text || '' - if (isReactionEvent && channel && messageTs && botToken) { - text = await fetchSlackMessageText(channel, messageTs, botToken) - } - - const rawFiles: any[] = rawEvent?.files ?? [] - const hasFiles = rawFiles.length > 0 - - let files: any[] = [] - if (hasFiles && includeFiles && botToken) { - files = await downloadSlackFiles(rawFiles, botToken) - } else if (hasFiles && includeFiles && !botToken) { - logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') - } - - return { - event: { - event_type: eventType, - channel, - channel_name: '', - user: rawEvent?.user || '', - user_name: '', - text, - timestamp: messageTs, - thread_ts: rawEvent?.thread_ts || '', - team_id: body?.team_id || rawEvent?.team || '', - event_id: body?.event_id || '', - reaction: rawEvent?.reaction || '', - item_user: rawEvent?.item_user || '', - hasFiles, - files, - }, - } - } - - if (foundWebhook.provider === 'webflow') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Form submission trigger - if (triggerId === 'webflow_form_submission') { - return { - siteId: body?.siteId || '', - formId: body?.formId || '', - name: body?.name || '', - id: body?.id || '', - submittedAt: body?.submittedAt || '', - data: body?.data || {}, - schema: body?.schema || {}, - formElementId: body?.formElementId || '', - } - } - - // Collection item triggers (created, changed, deleted) - // Webflow uses _cid for collection ID and _id for item ID - const { _cid, _id, ...itemFields } = body || {} - return { - siteId: body?.siteId || '', - collectionId: _cid || body?.collectionId || '', - payload: { - id: _id || '', - cmsLocaleId: itemFields?.cmsLocaleId || '', - lastPublished: itemFields?.lastPublished || itemFields?.['last-published'] || '', - lastUpdated: itemFields?.lastUpdated || itemFields?.['last-updated'] || '', - createdOn: itemFields?.createdOn || itemFields?.['created-on'] || '', - isArchived: itemFields?.isArchived || itemFields?._archived || false, - isDraft: itemFields?.isDraft || itemFields?._draft || false, - fieldData: itemFields, - }, - } - } - - if (foundWebhook.provider === 'generic') { - return body - } - - if (foundWebhook.provider === 'google_forms') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - const normalizeAnswers = (src: unknown): Record => { - if (!src || typeof src !== 'object') return {} - const out: Record = {} - for (const [k, v] of Object.entries(src as Record)) { - if (Array.isArray(v)) { - out[k] = v.length === 1 ? v[0] : v - } else { - out[k] = v as unknown - } - } - return out - } - - const responseId = body?.responseId || body?.id || '' - const createTime = body?.createTime || body?.timestamp || new Date().toISOString() - const lastSubmittedTime = body?.lastSubmittedTime || createTime - const formId = body?.formId || providerConfig.formId || '' - const includeRaw = providerConfig.includeRawPayload !== false - - return { - responseId, - createTime, - lastSubmittedTime, - formId, - answers: normalizeAnswers(body?.answers), - ...(includeRaw ? { raw: body?.raw ?? body } : {}), - } - } - - if (foundWebhook.provider === 'github') { - const eventType = request.headers.get('x-github-event') || 'unknown' - const branch = body?.ref?.replace('refs/heads/', '') || '' - - return { - ...body, - event_type: eventType, - action: body?.action || '', - branch, - } - } - - if (foundWebhook.provider === 'typeform') { - const formResponse = body?.form_response || {} - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const includeDefinition = providerConfig.includeDefinition === true - - return { - event_id: body?.event_id || '', - event_type: body?.event_type || 'form_response', - form_id: formResponse.form_id || '', - token: formResponse.token || '', - submitted_at: formResponse.submitted_at || '', - landed_at: formResponse.landed_at || '', - calculated: formResponse.calculated || {}, - variables: formResponse.variables || [], - hidden: formResponse.hidden || {}, - answers: formResponse.answers || [], - ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), - ending: formResponse.ending || {}, - raw: body, - } - } - - if (foundWebhook.provider === 'linear') { - return { - action: body.action || '', - type: body.type || '', - webhookId: body.webhookId || '', - webhookTimestamp: body.webhookTimestamp || 0, - organizationId: body.organizationId || '', - createdAt: body.createdAt || '', - actor: body.actor || null, - data: body.data || null, - updatedFrom: body.updatedFrom || null, - } - } - - if (foundWebhook.provider === 'jira') { - const { extractIssueData, extractCommentData, extractWorklogData } = await import( - '@/triggers/jira/utils' - ) - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'jira_issue_commented') { - return extractCommentData(body) - } - if (triggerId === 'jira_worklog_created') { - return extractWorklogData(body) - } - return extractIssueData(body) - } - - if (foundWebhook.provider === 'confluence') { - const { - extractPageData, - extractCommentData, - extractBlogData, - extractAttachmentData, - extractSpaceData, - extractLabelData, - } = await import('@/triggers/confluence/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('confluence_comment_')) { - return extractCommentData(body) - } - if (triggerId?.startsWith('confluence_blog_')) { - return extractBlogData(body) - } - if (triggerId?.startsWith('confluence_attachment_')) { - return extractAttachmentData(body) - } - if (triggerId?.startsWith('confluence_space_')) { - return extractSpaceData(body) - } - if (triggerId?.startsWith('confluence_label_')) { - return extractLabelData(body) - } - // Generic webhook — preserve all entity fields since event type varies - if (triggerId === 'confluence_webhook') { - return { - timestamp: body.timestamp, - userAccountId: body.userAccountId, - accountType: body.accountType, - page: body.page || null, - comment: body.comment || null, - blog: body.blog || body.blogpost || null, - attachment: body.attachment || null, - space: body.space || null, - label: body.label || null, - content: body.content || null, - } - } - // Default: page events - return extractPageData(body) - } - - if (foundWebhook.provider === 'ashby') { - return { - ...(body.data || {}), - action: body.action, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'stripe') { - return body - } - - if (foundWebhook.provider === 'calendly') { - return { - event: body.event, - created_at: body.created_at, - created_by: body.created_by, - payload: body.payload, - } - } - - if (foundWebhook.provider === 'circleback') { - return { - id: body.id, - name: body.name, - createdAt: body.createdAt, - duration: body.duration, - url: body.url, - recordingUrl: body.recordingUrl, - tags: body.tags || [], - icalUid: body.icalUid, - attendees: body.attendees || [], - notes: body.notes || '', - actionItems: body.actionItems || [], - transcript: body.transcript || [], - insights: body.insights || {}, - meeting: body, - } - } - - if (foundWebhook.provider === 'grain') { - return { - type: body.type, - user_id: body.user_id, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'fireflies') { - return { - meetingId: body.meetingId || '', - eventType: body.eventType || 'Transcription completed', - clientReferenceId: body.clientReferenceId || '', - } - } - - if (foundWebhook.provider === 'attio') { - const { - extractAttioRecordData, - extractAttioRecordUpdatedData, - extractAttioRecordMergedData, - extractAttioNoteData, - extractAttioTaskData, - extractAttioCommentData, - extractAttioListEntryData, - extractAttioListEntryUpdatedData, - extractAttioListData, - extractAttioWorkspaceMemberData, - extractAttioGenericData, - } = await import('@/triggers/attio/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'attio_record_updated') { - return extractAttioRecordUpdatedData(body) - } - if (triggerId === 'attio_record_merged') { - return extractAttioRecordMergedData(body) - } - if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { - return extractAttioRecordData(body) - } - if (triggerId?.startsWith('attio_note_')) { - return extractAttioNoteData(body) - } - if (triggerId?.startsWith('attio_task_')) { - return extractAttioTaskData(body) - } - if (triggerId?.startsWith('attio_comment_')) { - return extractAttioCommentData(body) - } - if (triggerId === 'attio_list_entry_updated') { - return extractAttioListEntryUpdatedData(body) - } - if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { - return extractAttioListEntryData(body) - } - if ( - triggerId === 'attio_list_created' || - triggerId === 'attio_list_updated' || - triggerId === 'attio_list_deleted' - ) { - return extractAttioListData(body) - } - if (triggerId === 'attio_workspace_member_created') { - return extractAttioWorkspaceMemberData(body) - } - return extractAttioGenericData(body) - } - - return body -} - -/** - * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 - * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) - * @param signature - Authorization header value (should start with 'HMAC ') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateMicrosoftTeamsSignature( - hmacSecret: string, - signature: string, - body: string -): boolean { - try { - if (!hmacSecret || !signature || !body) { - return false - } - - if (!signature.startsWith('HMAC ')) { - return false - } - - const providedSignature = signature.substring(5) - - const secretBytes = Buffer.from(hmacSecret, 'base64') - const bodyBytes = Buffer.from(body, 'utf8') - const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Microsoft Teams signature:', error) - return false - } -} - -/** - * Validates a Typeform webhook request signature using HMAC SHA-256 - * @param secret - Typeform webhook secret (plain text) - * @param signature - Typeform-Signature header value (should be in format 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateTypeformSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Typeform signature:', error) - return false - } -} - -/** - * Validates a Linear webhook request signature using HMAC SHA-256 - * @param secret - Linear webhook secret (plain text) - * @param signature - Linear-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateLinearSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Linear signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Linear signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Linear signature:', error) - return false - } -} - -/** - * Validates an Attio webhook request signature using HMAC SHA-256 - * @param secret - Attio webhook signing secret (plain text) - * @param signature - Attio-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAttioSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Attio signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Attio signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Attio signature:', error) - return false - } -} - -/** - * Validates a Circleback webhook request signature using HMAC SHA-256 - * @param secret - Circleback signing secret (plain text) - * @param signature - x-signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCirclebackSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Circleback signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Circleback signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Circleback signature:', error) - return false - } -} - -/** - * Validates a Jira webhook request signature using HMAC SHA-256 - * @param secret - Jira webhook secret (plain text) - * @param signature - X-Hub-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateJiraSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Jira signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Jira signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Jira signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Jira signature:', error) - return false - } -} - -/** - * Validates a Fireflies webhook request signature using HMAC SHA-256 - * @param secret - Fireflies webhook secret (16-32 characters) - * @param signature - x-hub-signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateFirefliesSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Fireflies signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Fireflies signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Fireflies signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Fireflies signature:', error) - return false - } -} - -/** - * Validates an Ashby webhook signature using HMAC-SHA256. - * Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header. - * @param secretToken - The secret token configured when creating the webhook - * @param signature - Ashby-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAshbySignature( - secretToken: string, - signature: string, - body: string -): boolean { - try { - if (!secretToken || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Ashby signature:', error) - return false - } -} - -/** - * Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1 - * @param secret - GitHub webhook secret (plain text) - * @param signature - X-Hub-Signature-256 or X-Hub-Signature header value (format: 'sha256=' or 'sha1=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateGitHubSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('GitHub signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - let algorithm: 'sha256' | 'sha1' - let providedSignature: string - - if (signature.startsWith('sha256=')) { - algorithm = 'sha256' - providedSignature = signature.substring(7) - } else if (signature.startsWith('sha1=')) { - algorithm = 'sha1' - providedSignature = signature.substring(5) - } else { - logger.warn('GitHub signature has invalid format', { - signature: `${signature.substring(0, 10)}...`, - }) - return false - } - - const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') - - logger.debug('GitHub signature comparison', { - algorithm, - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating GitHub signature:', error) - return false - } -} - -/** - * Process webhook provider-specific verification - */ -export function verifyProviderWebhook( - foundWebhook: any, - request: NextRequest, - requestId: string -): NextResponse | null { - const authHeader = request.headers.get('authorization') - const providerConfig = (foundWebhook.providerConfig as Record) || {} - switch (foundWebhook.provider) { - case 'github': - break - case 'stripe': - break - case 'gmail': - break - case 'telegram': { - // Check User-Agent to ensure it's not blocked by middleware - const userAgent = request.headers.get('user-agent') || '' - - if (!userAgent) { - logger.warn( - `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` - ) - } - - // Telegram uses IP addresses in specific ranges - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - break - } - case 'microsoft-teams': - break - case 'generic': - if (providerConfig.requireAuth) { - let isAuthenticated = false - if (providerConfig.token) { - const bearerMatch = authHeader?.match(/^bearer\s+(.+)$/i) - const providedToken = bearerMatch ? bearerMatch[1] : null - if (providedToken === providerConfig.token) { - isAuthenticated = true - } - if (!isAuthenticated && providerConfig.secretHeaderName) { - const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) - if (customHeaderValue === providerConfig.token) { - isAuthenticated = true - } - } - if (!isAuthenticated) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } - } - if ( - providerConfig.allowedIps && - Array.isArray(providerConfig.allowedIps) && - providerConfig.allowedIps.length > 0 - ) { - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { - logger.warn( - `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` - ) - return new NextResponse('Forbidden - IP not allowed', { - status: 403, - }) - } - } - break - default: - if (providerConfig.token) { - const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null - if (!providedToken || providedToken !== providerConfig.token) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - } - - return null -} - -/** - * Process Airtable payloads - */ -export async function fetchAndProcessAirtablePayloads( - webhookData: any, - workflowData: any, - requestId: string // Original request ID from the ping, used for the final execution log -) { - // Logging handles all error logging - let currentCursor: number | null = null - let mightHaveMore = true - let payloadsFetched = 0 - let apiCallCount = 0 - // Use a Map to consolidate changes per record ID - const consolidatedChangesMap = new Map() - // Capture raw payloads from Airtable for exposure to workflows - const allPayloads = [] - const localProviderConfig = { - ...((webhookData.providerConfig as Record) || {}), - } - - try { - // --- Essential IDs & Config from localProviderConfig --- - const baseId = localProviderConfig.baseId - const airtableWebhookId = localProviderConfig.externalId - - if (!baseId || !airtableWebhookId) { - logger.error( - `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` - ) - return - } - - const credentialId: string | undefined = localProviderConfig.credentialId - if (!credentialId) { - logger.error( - `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` - ) - return - } - - const resolvedAirtable = await resolveOAuthAccountId(credentialId) - if (!resolvedAirtable) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` - ) - return - } - - let ownerUserId: string | null = null - try { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedAirtable.accountId)) - .limit(1) - ownerUserId = rows.length ? rows[0].userId : null - } catch (_e) { - ownerUserId = null - } - - if (!ownerUserId) { - logger.error( - `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` - ) - return - } - - const storedCursor = localProviderConfig.externalWebhookCursor - - if (storedCursor === undefined || storedCursor === null) { - logger.info( - `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` - ) - localProviderConfig.externalWebhookCursor = null - - try { - await db - .update(webhook) - .set({ - providerConfig: { - ...localProviderConfig, - externalWebhookCursor: null, - }, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = null - logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) - } catch (initError: any) { - logger.error(`[${requestId}] Failed to initialize cursor in DB`, { - webhookId: webhookData.id, - error: initError.message, - stack: initError.stack, - }) - } - } - - if (storedCursor && typeof storedCursor === 'number') { - currentCursor = storedCursor - } else { - currentCursor = null - } - - let accessToken: string | null = null - try { - accessToken = await refreshAccessTokenIfNeeded( - resolvedAirtable.accountId, - ownerUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` - ) - throw new Error('Airtable access token not found.') - } - } catch (tokenError: any) { - logger.error( - `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, - { - error: tokenError.message, - stack: tokenError.stack, - credentialId, - } - ) - return - } - - const airtableApiBase = 'https://api.airtable.com/v0' - - // --- Polling Loop --- - while (mightHaveMore) { - apiCallCount++ - // Safety break - if (apiCallCount > 10) { - mightHaveMore = false - break - } - - const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` - const queryParams = new URLSearchParams() - if (currentCursor !== null) { - queryParams.set('cursor', currentCursor.toString()) - } - const fullUrl = `${apiUrl}?${queryParams.toString()}` - - try { - const fetchStartTime = Date.now() - const response = await fetch(fullUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - const responseBody = await response.json() - - if (!response.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || - responseBody.error || - `Airtable API error Status ${response.status}` - logger.error( - `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { - webhookId: webhookData.id, - status: response.status, - error: errorMessage, - } - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - - const receivedPayloads = responseBody.payloads || [] - - // --- Process and Consolidate Changes --- - if (receivedPayloads.length > 0) { - payloadsFetched += receivedPayloads.length - // Keep the raw payloads for later exposure to the workflow - for (const p of receivedPayloads) { - allPayloads.push(p) - } - let changeCount = 0 - for (const payload of receivedPayloads) { - if (payload.changedTablesById) { - for (const [tableId, tableChangesUntyped] of Object.entries( - payload.changedTablesById - )) { - const tableChanges = tableChangesUntyped as any // Assert type - - // Handle created records - if (tableChanges.createdRecordsById) { - const createdCount = Object.keys(tableChanges.createdRecordsById).length - changeCount += createdCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.createdRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - if (existingChange) { - // Record was created and possibly updated within the same batch - existingChange.changedFields = { - ...existingChange.changedFields, - ...(recordData.cellValuesByFieldId || {}), - } - // Keep changeType as 'created' if it started as created - } else { - // New creation - consolidatedChangesMap.set(recordId, { - tableId: tableId, - recordId: recordId, - changeType: 'created', - changedFields: recordData.cellValuesByFieldId || {}, - }) - } - } - } - - // Handle updated records - if (tableChanges.changedRecordsById) { - const updatedCount = Object.keys(tableChanges.changedRecordsById).length - changeCount += updatedCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.changedRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - const currentFields = recordData.current?.cellValuesByFieldId || {} - - if (existingChange) { - // Existing record was updated again - existingChange.changedFields = { - ...existingChange.changedFields, - ...currentFields, - } - // Ensure type is 'updated' if it was previously 'created' - existingChange.changeType = 'updated' - // Do not update previousFields again - } else { - // First update for this record in the batch - const newChange: AirtableChange = { - tableId: tableId, - recordId: recordId, - changeType: 'updated', - changedFields: currentFields, - } - if (recordData.previous?.cellValuesByFieldId) { - newChange.previousFields = recordData.previous.cellValuesByFieldId - } - consolidatedChangesMap.set(recordId, newChange) - } - } - } - // TODO: Handle deleted records (`destroyedRecordIds`) if needed - } - } - } - } - - const nextCursor = responseBody.cursor - mightHaveMore = responseBody.mightHaveMore || false - - if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { - currentCursor = nextCursor - - // Follow exactly the old implementation - use awaited update instead of parallel - const updatedConfig = { - ...localProviderConfig, - externalWebhookCursor: currentCursor, - } - try { - // Force a complete object update to ensure consistency in serverless env - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, // Use full object - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too - } catch (dbError: any) { - logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { - webhookId: webhookData.id, - cursor: currentCursor, - error: dbError.message, - }) - // Error logging handled by logging session - mightHaveMore = false - throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly - } - } else if (!nextCursor || typeof nextCursor !== 'number') { - logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { - webhookId: webhookData.id, - apiCall: apiCallCount, - receivedCursor: nextCursor, - }) - mightHaveMore = false - } else if (nextCursor === currentCursor) { - mightHaveMore = false // Explicitly stop if cursor hasn't changed - } - } catch (fetchError: any) { - logger.error( - `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, - fetchError - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - } - // --- End Polling Loop --- - - // Convert map values to array for final processing - const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) - logger.info( - `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` - ) - - // --- Execute Workflow if we have changes (simplified - no lock check) --- - if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { - try { - // Build input exposing raw payloads and consolidated changes - const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null - const input: any = { - // Raw Airtable payloads as received from the API - payloads: allPayloads, - latestPayload, - // Consolidated, simplified changes for convenience - airtableChanges: finalConsolidatedChanges, - // Include webhook metadata for resolver fallbacks - webhook: { - data: { - provider: 'airtable', - providerConfig: webhookData.providerConfig, - payload: latestPayload, - }, - }, - } - - // CRITICAL EXECUTION TRACE POINT - logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, - { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - timestamp: new Date().toISOString(), - firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', - } - ) - - // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - rawPayloadCount: allPayloads.length, - timestamp: new Date().toISOString(), - }) - - return input - } catch (processingError: any) { - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { - workflowId: workflowData.id, - error: processingError.message, - stack: processingError.stack, - timestamp: new Date().toISOString(), - }) - - throw processingError - } - } else { - // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { - workflowId: workflowData.id, - apiCallCount, - webhookId: webhookData.id, - }) - } - } catch (error) { - // Catch any unexpected errors during the setup/polling logic itself - logger.error( - `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, - { - webhookId: webhookData.id, - workflowId: workflowData.id, - error: (error as Error).message, - } - ) - // Error logging handled by logging session - } -} - -// Define an interface for AirtableChange -export interface AirtableChange { - tableId: string - recordId: string - changeType: 'created' | 'updated' - changedFields: Record // { fieldId: newValue } - previousFields?: Record // { fieldId: previousValue } (optional) -} - /** * Result of syncing webhooks for a credential set */ @@ -2251,7 +51,7 @@ export async function syncWebhooksForCredentialSet(params: { basePath: string credentialSetId: string oauthProviderId: string - providerConfig: Record + providerConfig: Record requestId: string tx?: DbOrTx deploymentVersionId?: string @@ -2291,7 +91,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` ) - // Get existing webhooks for this workflow+block that belong to this credential set const existingWebhooks = await dbCtx .select() .from(webhook) @@ -2310,7 +109,6 @@ export async function syncWebhooksForCredentialSet(params: { ) ) - // Filter to only webhooks belonging to this credential set const credentialSetWebhooks = existingWebhooks.filter( (wh) => wh.credentialSetId === credentialSetId ) @@ -2319,12 +117,11 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` ) - // Build maps for efficient lookup const existingByCredentialId = new Map() for (const wh of credentialSetWebhooks) { - const config = wh.providerConfig as Record + const config = wh.providerConfig as Record if (config?.credentialId) { - existingByCredentialId.set(config.credentialId, wh) + existingByCredentialId.set(config.credentialId as string, wh) } } @@ -2347,21 +144,18 @@ export async function syncWebhooksForCredentialSet(params: { failed: [], } - // Process each credential in the set for (const cred of credentials) { try { const existingWebhook = existingByCredentialId.get(cred.credentialId) if (existingWebhook) { - // Update existing webhook - preserve state fields - const existingConfig = existingWebhook.providerConfig as Record + const existingConfig = existingWebhook.providerConfig as Record const updatedConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, - // Preserve state fields from existing config historyId: existingConfig?.historyId, lastCheckedTimestamp: existingConfig?.lastCheckedTimestamp, setupCompleted: existingConfig?.setupCompleted, @@ -2390,7 +184,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` ) } else { - // Create new webhook for this credential const webhookId = generateShortId() const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` @@ -2398,7 +191,7 @@ export async function syncWebhooksForCredentialSet(params: { const newConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, userId: cred.userId, @@ -2411,7 +204,7 @@ export async function syncWebhooksForCredentialSet(params: { path: webhookPath, provider, providerConfig: newConfig, - credentialSetId, // Indexed column for efficient credential set queries + credentialSetId, isActive: true, ...(deploymentVersionId ? { deploymentVersionId } : {}), createdAt: new Date(), @@ -2441,7 +234,6 @@ export async function syncWebhooksForCredentialSet(params: { } } - // Delete webhooks for credentials no longer in the set for (const [credentialId, existingWebhook] of existingByCredentialId) { if (!credentialIdsInSet.has(credentialId)) { try { @@ -2489,7 +281,6 @@ export async function syncAllWebhooksForCredentialSet( const syncLogger = createLogger('CredentialSetMembershipSync') syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) - // Find all webhooks that use this credential set using the indexed column const webhooksForSet = await dbCtx .select({ webhook }) .from(webhook) @@ -2516,12 +307,10 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } } - // Group webhooks by workflow+block to find unique triggers const triggerGroups = new Map() for (const row of webhooksForSet) { const wh = row.webhook const key = `${wh.workflowId}:${wh.blockId}` - // Keep the first webhook as representative (they all have same config) if (!triggerGroups.has(key)) { triggerGroups.set(key, wh) } @@ -2541,15 +330,15 @@ export async function syncAllWebhooksForCredentialSet( continue } - const config = representativeWebhook.providerConfig as Record + const config = representativeWebhook.providerConfig as Record const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config - // Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks) - const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path + const basePath = + (config.basePath as string) || representativeWebhook.blockId || representativeWebhook.path try { - const result = await syncWebhooksForCredentialSet({ + const syncResult = await syncWebhooksForCredentialSet({ workflowId: representativeWebhook.workflowId, blockId: representativeWebhook.blockId || '', provider: representativeWebhook.provider, @@ -2563,11 +352,11 @@ export async function syncAllWebhooksForCredentialSet( }) workflowsUpdated++ - totalCreated += result.created - totalDeleted += result.deleted + totalCreated += syncResult.created + totalDeleted += syncResult.deleted syncLogger.debug( - `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted` + `[${requestId}] Synced webhooks for ${key}: ${syncResult.created} created, ${syncResult.deleted} deleted` ) } catch (error) { syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) @@ -2580,332 +369,3 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated, totalCreated, totalDeleted } } - -/** - * Configure Gmail polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureGmailPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('GmailWebhookSetup') - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - const resolvedGmail = await resolveOAuthAccountId(credentialId) - if (!resolvedGmail) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedGmail.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedGmail.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - - const maxEmailsPerPoll = - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25 - - const pollingInterval = - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5 - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Gmail polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure Outlook polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureOutlookPolling( - webhookData: any, - requestId: string -): Promise { - const logger = createLogger('OutlookWebhookSetup') - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - const resolvedOutlook = await resolveOAuthAccountId(credentialId) - if (!resolvedOutlook) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedOutlook.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedOutlook.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll: - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25, - pollingInterval: - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - folderIds: providerConfig.folderIds || ['inbox'], - folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Outlook polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure RSS polling for a webhook - */ -export async function configureRssPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('RssWebhookSetup') - logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - lastCheckedTimestamp: now.toISOString(), - lastSeenGuids: [], - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure RSS polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -/** - * Configure IMAP polling for a webhook - */ -export async function configureImapPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('ImapWebhookSetup') - logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { - logger.error( - `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` - ) - return false - } - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, - mailbox: providerConfig.mailbox || 'INBOX', - searchCriteria: providerConfig.searchCriteria || 'UNSEEN', - markAsRead: providerConfig.markAsRead || false, - includeAttachments: providerConfig.includeAttachments !== false, - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure IMAP polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { - if (!twiml) { - return twiml - } - - // Replace [Tag] with and [/Tag] with - return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') -} - -/** - * Validates a Cal.com webhook request signature using HMAC SHA-256 - * @param secret - Cal.com webhook secret (plain text) - * @param signature - X-Cal-Signature-256 header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCalcomSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Cal.com signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - // Cal.com sends signature in format: sha256= - // We need to strip the prefix before comparing - let providedSignature: string - if (signature.startsWith('sha256=')) { - providedSignature = signature.substring(7) - } else { - // If no prefix, use as-is (for backwards compatibility) - providedSignature = signature - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Cal.com signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Cal.com signature:', error) - return false - } -}