mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
refactor(webhooks): decompose formatWebhookInput into per-provider formatInput methods
Move all provider-specific input formatting from the monolithic formatWebhookInput switch statement into each provider's handler file. Delete formatWebhookInput and all its helper functions (fetchWithDNSPinning, formatTeamsGraphNotification, Slack file helpers, convertSquareBracketsToTwiML) from utils.server.ts. Create new handler files for gmail, outlook, rss, imap, and calendly providers. Update webhook-execution.ts to use handler.formatInput as the primary path with raw body passthrough as fallback. utils.server.ts reduced from ~1600 lines to ~370 lines containing only credential-sync functions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,17 +11,17 @@ import { generateId, generateShortId } from '@/lib/core/utils/uuid'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
|
||||
import {
|
||||
configureGmailPolling,
|
||||
configureOutlookPolling,
|
||||
configureRssPolling,
|
||||
} from '@/lib/webhooks/polling-config'
|
||||
import {
|
||||
cleanupExternalWebhook,
|
||||
createExternalWebhookSubscription,
|
||||
shouldRecreateExternalWebhookSubscription,
|
||||
} from '@/lib/webhooks/provider-subscriptions'
|
||||
import { mergeNonUserFields } from '@/lib/webhooks/utils'
|
||||
import {
|
||||
configureGmailPolling,
|
||||
configureOutlookPolling,
|
||||
configureRssPolling,
|
||||
} from '@/lib/webhooks/polling-config'
|
||||
import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
|
||||
|
||||
@@ -13,7 +13,6 @@ 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 { getProviderHandler } from '@/lib/webhooks/providers'
|
||||
import { formatWebhookInput } from '@/lib/webhooks/utils.server'
|
||||
import {
|
||||
executeWorkflowCore,
|
||||
wasExecutionFinalizedByCore,
|
||||
@@ -317,12 +316,12 @@ async function executeWebhookJobInternal(
|
||||
let input: Record<string, unknown> | null = null
|
||||
let skipMessage: string | undefined
|
||||
|
||||
if (handler.formatInput) {
|
||||
const webhookRecord = webhookRows[0]
|
||||
if (!webhookRecord) {
|
||||
throw new Error(`Webhook record not found: ${payload.webhookId}`)
|
||||
}
|
||||
const webhookRecord = webhookRows[0]
|
||||
if (!webhookRecord) {
|
||||
throw new Error(`Webhook record not found: ${payload.webhookId}`)
|
||||
}
|
||||
|
||||
if (handler.formatInput) {
|
||||
const result = await handler.formatInput({
|
||||
webhook: webhookRecord,
|
||||
workflow: { id: payload.workflowId, userId: payload.userId },
|
||||
@@ -333,35 +332,13 @@ async function executeWebhookJobInternal(
|
||||
input = result.input as Record<string, unknown> | null
|
||||
skipMessage = result.skip?.message
|
||||
} else {
|
||||
const actualWebhook =
|
||||
webhookRows.length > 0
|
||||
? webhookRows[0]
|
||||
: {
|
||||
provider: payload.provider,
|
||||
blockId: payload.blockId,
|
||||
providerConfig: {},
|
||||
}
|
||||
input = payload.body as Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const mockWorkflow = {
|
||||
id: payload.workflowId,
|
||||
userId: payload.userId,
|
||||
}
|
||||
const mockRequest = {
|
||||
headers: new Map(Object.entries(payload.headers)),
|
||||
} as unknown as Parameters<typeof formatWebhookInput>[3]
|
||||
|
||||
input = (await formatWebhookInput(
|
||||
actualWebhook,
|
||||
mockWorkflow,
|
||||
payload.body,
|
||||
mockRequest
|
||||
)) as Record<string, unknown> | null
|
||||
|
||||
if (!input && handler.handleEmptyInput) {
|
||||
const skipResult = handler.handleEmptyInput(requestId)
|
||||
if (skipResult) {
|
||||
skipMessage = skipResult.message
|
||||
}
|
||||
if (!input && handler.handleEmptyInput) {
|
||||
const skipResult = handler.handleEmptyInput(requestId)
|
||||
if (skipResult) {
|
||||
skipMessage = skipResult.message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import type { NextRequest } from 'next/server'
|
||||
import { generateShortId } from '@/lib/core/utils/uuid'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
|
||||
import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config'
|
||||
import {
|
||||
cleanupExternalWebhook,
|
||||
createExternalWebhookSubscription,
|
||||
shouldRecreateExternalWebhookSubscription,
|
||||
} from '@/lib/webhooks/provider-subscriptions'
|
||||
import { configureGmailPolling, configureOutlookPolling } from '@/lib/webhooks/polling-config'
|
||||
import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
|
||||
@@ -2,10 +2,7 @@ import { db } from '@sim/db'
|
||||
import { account, webhook } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
/**
|
||||
* Configure Gmail polling for a webhook.
|
||||
|
||||
@@ -2,11 +2,8 @@ import { db } from '@sim/db'
|
||||
import { account, webhook } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import {
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import type { FormatInputContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Airtable')
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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 type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
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 }
|
||||
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)
|
||||
@@ -20,6 +28,17 @@ function validateAshbySignature(secretToken: string, signature: string, body: st
|
||||
}
|
||||
|
||||
export const ashbyHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return {
|
||||
input: {
|
||||
...((b.data as Record<string, unknown>) || {}),
|
||||
action: b.action,
|
||||
data: b.data || {},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
verifyAuth: createHmacVerifier({
|
||||
configKey: 'secretToken',
|
||||
headerName: 'ashby-signature',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type {
|
||||
AuthContext,
|
||||
EventMatchContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
@@ -13,11 +15,21 @@ 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 })
|
||||
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 })
|
||||
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)
|
||||
@@ -87,4 +99,59 @@ export const attioHandler: WebhookProviderHandler = {
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const {
|
||||
extractAttioRecordData,
|
||||
extractAttioRecordUpdatedData,
|
||||
extractAttioRecordMergedData,
|
||||
extractAttioNoteData,
|
||||
extractAttioTaskData,
|
||||
extractAttioCommentData,
|
||||
extractAttioListEntryData,
|
||||
extractAttioListEntryUpdatedData,
|
||||
extractAttioListData,
|
||||
extractAttioWorkspaceMemberData,
|
||||
extractAttioGenericData,
|
||||
} = await import('@/triggers/attio/utils')
|
||||
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
|
||||
if (triggerId === 'attio_record_updated') {
|
||||
return { input: extractAttioRecordUpdatedData(body) }
|
||||
}
|
||||
if (triggerId === 'attio_record_merged') {
|
||||
return { input: extractAttioRecordMergedData(body) }
|
||||
}
|
||||
if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') {
|
||||
return { input: extractAttioRecordData(body) }
|
||||
}
|
||||
if (triggerId?.startsWith('attio_note_')) {
|
||||
return { input: extractAttioNoteData(body) }
|
||||
}
|
||||
if (triggerId?.startsWith('attio_task_')) {
|
||||
return { input: extractAttioTaskData(body) }
|
||||
}
|
||||
if (triggerId?.startsWith('attio_comment_')) {
|
||||
return { input: extractAttioCommentData(body) }
|
||||
}
|
||||
if (triggerId === 'attio_list_entry_updated') {
|
||||
return { input: extractAttioListEntryUpdatedData(body) }
|
||||
}
|
||||
if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') {
|
||||
return { input: extractAttioListEntryData(body) }
|
||||
}
|
||||
if (
|
||||
triggerId === 'attio_list_created' ||
|
||||
triggerId === 'attio_list_updated' ||
|
||||
triggerId === 'attio_list_deleted'
|
||||
) {
|
||||
return { input: extractAttioListData(body) }
|
||||
}
|
||||
if (triggerId === 'attio_workspace_member_created') {
|
||||
return { input: extractAttioWorkspaceMemberData(body) }
|
||||
}
|
||||
return { input: extractAttioGenericData(body) }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,14 +9,27 @@ 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 })
|
||||
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 }
|
||||
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 })
|
||||
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)
|
||||
|
||||
19
apps/sim/lib/webhooks/providers/calendly.ts
Normal file
19
apps/sim/lib/webhooks/providers/calendly.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const calendlyHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return {
|
||||
input: {
|
||||
event: b.event,
|
||||
created_at: b.created_at,
|
||||
created_by: b.created_by,
|
||||
payload: b.payload,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
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 type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Circleback')
|
||||
@@ -9,11 +13,21 @@ 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 })
|
||||
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 })
|
||||
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)
|
||||
@@ -22,6 +36,28 @@ function validateCirclebackSignature(secret: string, signature: string, body: st
|
||||
}
|
||||
|
||||
export const circlebackHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
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')
|
||||
|
||||
@@ -14,6 +19,52 @@ export const confluenceHandler: WebhookProviderHandler = {
|
||||
providerLabel: 'Confluence',
|
||||
}),
|
||||
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const {
|
||||
extractPageData,
|
||||
extractCommentData,
|
||||
extractBlogData,
|
||||
extractAttachmentData,
|
||||
extractSpaceData,
|
||||
extractLabelData,
|
||||
} = await import('@/triggers/confluence/utils')
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
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<string, unknown>
|
||||
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<string, unknown>).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<string, unknown>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Fireflies')
|
||||
@@ -9,16 +13,28 @@ 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 })
|
||||
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) })
|
||||
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 })
|
||||
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)
|
||||
@@ -27,6 +43,17 @@ function validateFirefliesSignature(secret: string, signature: string, body: str
|
||||
}
|
||||
|
||||
export const firefliesHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
|
||||
import type {
|
||||
AuthContext,
|
||||
EventFilterContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
ProcessFilesContext,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
@@ -84,6 +86,10 @@ export const genericHandler: WebhookProviderHandler = {
|
||||
return null
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
return { input: body }
|
||||
},
|
||||
|
||||
async processInputFiles({
|
||||
input,
|
||||
blocks,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type {
|
||||
AuthContext,
|
||||
EventMatchContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
@@ -13,7 +15,11 @@ 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 })
|
||||
logger.warn('GitHub signature validation missing required fields', {
|
||||
hasSecret: !!secret,
|
||||
hasSignature: !!signature,
|
||||
hasBody: !!body,
|
||||
})
|
||||
return false
|
||||
}
|
||||
let algorithm: 'sha256' | 'sha1'
|
||||
@@ -25,11 +31,20 @@ function validateGitHubSignature(secret: string, signature: string, body: string
|
||||
algorithm = 'sha1'
|
||||
providedSignature = signature.substring(5)
|
||||
} else {
|
||||
logger.warn('GitHub signature has invalid format', { signature: `${signature.substring(0, 10)}...` })
|
||||
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 })
|
||||
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)
|
||||
@@ -63,6 +78,16 @@ export const githubHandler: WebhookProviderHandler = {
|
||||
return null
|
||||
},
|
||||
|
||||
async formatInput({ body, headers }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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,
|
||||
|
||||
15
apps/sim/lib/webhooks/providers/gmail.ts
Normal file
15
apps/sim/lib/webhooks/providers/gmail.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const gmailHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
if (b && typeof b === 'object' && 'email' in b) {
|
||||
return { input: { email: b.email, timestamp: b.timestamp } }
|
||||
}
|
||||
return { input: b }
|
||||
},
|
||||
}
|
||||
@@ -1,11 +1,48 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
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<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
const normalizeAnswers = (src: unknown): Record<string, unknown> => {
|
||||
if (!src || typeof src !== 'object') return {}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(src as Record<string, unknown>)) {
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import type {
|
||||
EventFilterContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { skipByEventTypes } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Grain')
|
||||
@@ -25,6 +30,11 @@ export const grainHandler: WebhookProviderHandler = {
|
||||
return skipByEventTypes(ctx, 'Grain', logger)
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
return { input: { type: b.type, user_id: b.user_id, data: b.data || {} } }
|
||||
},
|
||||
|
||||
extractIdempotencyId(body: unknown) {
|
||||
const obj = body as Record<string, unknown>
|
||||
const data = obj.data as Record<string, unknown> | undefined
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import type {
|
||||
EventMatchContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProvider:HubSpot')
|
||||
|
||||
@@ -40,6 +45,24 @@ export const hubspotHandler: WebhookProviderHandler = {
|
||||
return true
|
||||
},
|
||||
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const events = Array.isArray(b) ? b : [b]
|
||||
const event = events[0] as Record<string, unknown> | 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<string, unknown>
|
||||
|
||||
31
apps/sim/lib/webhooks/providers/imap.ts
Normal file
31
apps/sim/lib/webhooks/providers/imap.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const imapHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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 }
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
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 type {
|
||||
EventMatchContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Jira')
|
||||
@@ -9,16 +14,28 @@ 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 })
|
||||
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) })
|
||||
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 })
|
||||
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)
|
||||
@@ -34,6 +51,21 @@ export const jiraHandler: WebhookProviderHandler = {
|
||||
providerLabel: 'Jira',
|
||||
}),
|
||||
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const { extractIssueData, extractCommentData, extractWorklogData } = await import(
|
||||
'@/triggers/jira/utils'
|
||||
)
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
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<string, unknown>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Linear')
|
||||
@@ -9,11 +13,21 @@ 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 })
|
||||
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 })
|
||||
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)
|
||||
@@ -29,6 +43,23 @@ export const linearHandler: WebhookProviderHandler = {
|
||||
providerLabel: 'Linear',
|
||||
}),
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
const data = obj.data as Record<string, unknown> | undefined
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
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 { 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 type {
|
||||
AuthContext,
|
||||
EventFilterContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
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 {
|
||||
function validateMicrosoftTeamsSignature(
|
||||
hmacSecret: string,
|
||||
signature: string,
|
||||
body: string
|
||||
): boolean {
|
||||
try {
|
||||
if (!hmacSecret || !signature || !body) { return false }
|
||||
if (!signature.startsWith('HMAC ')) { return false }
|
||||
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')
|
||||
@@ -45,6 +65,389 @@ function parseFirstNotification(
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchWithDNSPinning(
|
||||
url: string,
|
||||
accessToken: string,
|
||||
requestId: string
|
||||
): Promise<SecureFetchResponse | null> {
|
||||
try {
|
||||
const urlValidation = await validateUrlWithDNS(url, 'contentUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url })
|
||||
return null
|
||||
}
|
||||
const headers: Record<string, string> = {}
|
||||
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<string, unknown>,
|
||||
foundWebhook: Record<string, unknown>,
|
||||
foundWorkflow: { id: string; userId: string },
|
||||
request: { headers: Map<string, string> }
|
||||
): Promise<unknown> {
|
||||
const notification = (body.value as unknown[])?.[0] as Record<string, unknown> | 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<string, unknown>)?.resourceData as
|
||||
| Record<string, unknown>
|
||||
| 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<string, unknown>)?.resourceData
|
||||
) {
|
||||
const resourceData = ((body.value as unknown[])[0] as Record<string, unknown>)
|
||||
.resourceData as Record<string, unknown>
|
||||
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<string, unknown>)?.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<string, unknown>) || {}
|
||||
const credentialId = providerConfig.credentialId
|
||||
const includeAttachments = providerConfig.includeAttachments !== false
|
||||
|
||||
let message: Record<string, unknown> | 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<string, unknown>
|
||||
|
||||
if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) {
|
||||
const attachments = Array.isArray(message?.attachments)
|
||||
? (message.attachments as Record<string, unknown>[])
|
||||
: []
|
||||
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<string, unknown>
|
||||
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<string, unknown> | 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<string, unknown>)?.content || ''
|
||||
const from = ((message.from as Record<string, unknown>)?.user as Record<string, unknown>) || {}
|
||||
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 = {
|
||||
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
|
||||
if (providerConfig.hmacSecret) {
|
||||
@@ -98,4 +501,64 @@ export const microsoftTeamsHandler: WebhookProviderHandler = {
|
||||
{ status: 500 }
|
||||
)
|
||||
},
|
||||
|
||||
async formatInput({
|
||||
body,
|
||||
webhook,
|
||||
workflow,
|
||||
headers,
|
||||
requestId,
|
||||
}: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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<string, string> }
|
||||
)
|
||||
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<string, unknown>
|
||||
const conversation = (b?.conversation || {}) as Record<string, unknown>
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
15
apps/sim/lib/webhooks/providers/outlook.ts
Normal file
15
apps/sim/lib/webhooks/providers/outlook.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const outlookHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
if (b && typeof b === 'object' && 'email' in b) {
|
||||
return { input: { email: b.email, timestamp: b.timestamp } }
|
||||
}
|
||||
return { input: b }
|
||||
},
|
||||
}
|
||||
@@ -4,17 +4,22 @@ 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 { 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 { 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'
|
||||
@@ -32,18 +37,23 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
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,
|
||||
grain: grainHandler,
|
||||
hubspot: hubspotHandler,
|
||||
imap: imapHandler,
|
||||
jira: jiraHandler,
|
||||
linear: linearHandler,
|
||||
'microsoft-teams': microsoftTeamsHandler,
|
||||
outlook: outlookHandler,
|
||||
rss: rssHandler,
|
||||
slack: slackHandler,
|
||||
stripe: stripeHandler,
|
||||
telegram: telegramHandler,
|
||||
|
||||
24
apps/sim/lib/webhooks/providers/rss.ts
Normal file
24
apps/sim/lib/webhooks/providers/rss.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
export const rssHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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 }
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,181 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
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<string, unknown>
|
||||
}
|
||||
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<Array<{ name: string; data: string; mimeType: string; size: number }>> {
|
||||
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<string, unknown>
|
||||
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<string> {
|
||||
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
|
||||
@@ -35,4 +211,68 @@ export const slackHandler: WebhookProviderHandler = {
|
||||
formatQueueErrorResponse() {
|
||||
return new NextResponse(null, { status: 200 })
|
||||
},
|
||||
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
const botToken = providerConfig.botToken as string | undefined
|
||||
const includeFiles = Boolean(providerConfig.includeFiles)
|
||||
|
||||
const rawEvent = b?.event as Record<string, unknown> | 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<string, unknown> | 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
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<FormatInputResult> {
|
||||
return { input: body }
|
||||
},
|
||||
|
||||
shouldSkipEvent(ctx: EventFilterContext) {
|
||||
return skipByEventTypes(ctx, 'Stripe', logger)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import type {
|
||||
AuthContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Telegram')
|
||||
|
||||
@@ -13,4 +18,81 @@ export const telegramHandler: WebhookProviderHandler = {
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const rawMessage = (b?.message ||
|
||||
b?.edited_message ||
|
||||
b?.channel_post ||
|
||||
b?.edited_channel_post) as Record<string, unknown> | 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<string, unknown> | 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,27 +2,59 @@ import crypto from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { AuthContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
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<string, unknown>): Promise<boolean> {
|
||||
async function validateTwilioSignature(
|
||||
authToken: string,
|
||||
signature: string,
|
||||
url: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!authToken || !signature || !url) {
|
||||
logger.warn('Twilio signature validation missing required fields', { hasAuthToken: !!authToken, hasSignature: !!signature, hasUrl: !!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 })
|
||||
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 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 })
|
||||
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)
|
||||
@@ -124,6 +156,47 @@ export const twilioVoiceHandler: WebhookProviderHandler = {
|
||||
})
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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 type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
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 }
|
||||
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)
|
||||
@@ -20,6 +28,30 @@ function validateTypeformSignature(secret: string, signature: string, body: stri
|
||||
}
|
||||
|
||||
export const typeformHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const formResponse = (b?.form_response || {}) as Record<string, unknown>
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
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',
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { EventFilterContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import type {
|
||||
EventFilterContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Webflow')
|
||||
|
||||
export const webflowHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body, webhook }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const providerConfig = (webhook.providerConfig as Record<string, unknown>) || {}
|
||||
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<string, unknown>)
|
||||
return {
|
||||
input: {
|
||||
siteId: b?.siteId || '',
|
||||
collectionId: (_cid || b?.collectionId || '') as string,
|
||||
payload: {
|
||||
id: (_id || '') as string,
|
||||
cmsLocaleId: (itemFields as Record<string, unknown>)?.cmsLocaleId || '',
|
||||
lastPublished:
|
||||
(itemFields as Record<string, unknown>)?.lastPublished ||
|
||||
(itemFields as Record<string, unknown>)?.['last-published'] ||
|
||||
'',
|
||||
lastUpdated:
|
||||
(itemFields as Record<string, unknown>)?.lastUpdated ||
|
||||
(itemFields as Record<string, unknown>)?.['last-updated'] ||
|
||||
'',
|
||||
createdOn:
|
||||
(itemFields as Record<string, unknown>)?.createdOn ||
|
||||
(itemFields as Record<string, unknown>)?.['created-on'] ||
|
||||
'',
|
||||
isArchived:
|
||||
(itemFields as Record<string, unknown>)?.isArchived ||
|
||||
(itemFields as Record<string, unknown>)?._archived ||
|
||||
false,
|
||||
isDraft:
|
||||
(itemFields as Record<string, unknown>)?.isDraft ||
|
||||
(itemFields as Record<string, unknown>)?._draft ||
|
||||
false,
|
||||
fieldData: itemFields,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) {
|
||||
const configuredCollectionId = providerConfig.collectionId as string | undefined
|
||||
if (configuredCollectionId) {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { webhook } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
|
||||
import type {
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProvider:WhatsApp')
|
||||
|
||||
@@ -74,6 +78,31 @@ export async function handleWhatsAppVerification(
|
||||
}
|
||||
|
||||
export const whatsappHandler: WebhookProviderHandler = {
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const entry = b?.entry as Array<Record<string, unknown>> | undefined
|
||||
const changes = entry?.[0]?.changes as Array<Record<string, unknown>> | undefined
|
||||
const data = changes?.[0]?.value as Record<string, unknown> | undefined
|
||||
const messages = (data?.messages as Array<Record<string, unknown>>) || []
|
||||
|
||||
if (messages.length > 0) {
|
||||
const message = messages[0]
|
||||
const metadata = data?.metadata as Record<string, unknown> | undefined
|
||||
const text = message.text as Record<string, unknown> | undefined
|
||||
return {
|
||||
input: {
|
||||
messageId: message.id,
|
||||
from: message.from,
|
||||
phoneNumberId: metadata?.phone_number_id,
|
||||
text: text?.body,
|
||||
timestamp: message.timestamp,
|
||||
raw: JSON.stringify(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
return { input: null }
|
||||
},
|
||||
|
||||
handleEmptyInput(requestId: string) {
|
||||
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
|
||||
return { message: 'No messages in WhatsApp payload' }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user