mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
feat(triggers): add Zoom webhook triggers (#3992)
* feat(triggers): add Zoom webhook triggers with challenge-response and signature verification Add 6 Zoom webhook triggers (meeting started/ended, participant joined/left, recording completed, generic webhook) with full Zoom protocol support including endpoint.url_validation challenge-response handling and x-zm-signature HMAC-SHA256 verification. * fix(triggers): use webhook.isActive instead of non-existent deletedAt column * fix(triggers): address PR review feedback for Zoom webhooks - Add 30s timestamp freshness check to prevent replay attacks - Return null from handleChallenge when no secret token found instead of responding with empty-key HMAC - Remove all `as any` casts from output builder functions * lint * fix(triggers): harden Zoom webhook security per PR review - verifyAuth now fails closed (401) when secretToken is missing - handleChallenge DB query filters by provider='zoom' to avoid cross-provider leaks - handleChallenge verifies x-zm-signature before responding to prevent HMAC oracle * fix(triggers): rename type to meeting_type to avoid TriggerOutput type collision * fix(triggers): make challenge signature verification mandatory, not optional * fix(triggers): fail closed on unknown trigger IDs and update Zoom landing page data - isZoomEventMatch now returns false for unrecognized trigger IDs - Update integrations.json with 6 Zoom triggers * fix(triggers): add missing id fields to Zoom trigger entries in integrations.json * fix(triggers): increase Zoom timestamp tolerance to 300s per Zoom docs
This commit is contained in:
@@ -5528,6 +5528,11 @@
|
||||
"name": "HubSpot Contact Deleted",
|
||||
"description": "Trigger workflow when a contact is deleted in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_contact_merged",
|
||||
"name": "HubSpot Contact Merged",
|
||||
"description": "Trigger workflow when contacts are merged in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_contact_privacy_deleted",
|
||||
"name": "HubSpot Contact Privacy Deleted",
|
||||
@@ -5538,6 +5543,11 @@
|
||||
"name": "HubSpot Contact Property Changed",
|
||||
"description": "Trigger workflow when any property of a contact is updated in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_contact_restored",
|
||||
"name": "HubSpot Contact Restored",
|
||||
"description": "Trigger workflow when a deleted contact is restored in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_company_created",
|
||||
"name": "HubSpot Company Created",
|
||||
@@ -5548,11 +5558,21 @@
|
||||
"name": "HubSpot Company Deleted",
|
||||
"description": "Trigger workflow when a company is deleted in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_company_merged",
|
||||
"name": "HubSpot Company Merged",
|
||||
"description": "Trigger workflow when companies are merged in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_company_property_changed",
|
||||
"name": "HubSpot Company Property Changed",
|
||||
"description": "Trigger workflow when any property of a company is updated in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_company_restored",
|
||||
"name": "HubSpot Company Restored",
|
||||
"description": "Trigger workflow when a deleted company is restored in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_conversation_creation",
|
||||
"name": "HubSpot Conversation Creation",
|
||||
@@ -5588,11 +5608,21 @@
|
||||
"name": "HubSpot Deal Deleted",
|
||||
"description": "Trigger workflow when a deal is deleted in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_deal_merged",
|
||||
"name": "HubSpot Deal Merged",
|
||||
"description": "Trigger workflow when deals are merged in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_deal_property_changed",
|
||||
"name": "HubSpot Deal Property Changed",
|
||||
"description": "Trigger workflow when any property of a deal is updated in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_deal_restored",
|
||||
"name": "HubSpot Deal Restored",
|
||||
"description": "Trigger workflow when a deleted deal is restored in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_ticket_created",
|
||||
"name": "HubSpot Ticket Created",
|
||||
@@ -5603,13 +5633,28 @@
|
||||
"name": "HubSpot Ticket Deleted",
|
||||
"description": "Trigger workflow when a ticket is deleted in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_ticket_merged",
|
||||
"name": "HubSpot Ticket Merged",
|
||||
"description": "Trigger workflow when tickets are merged in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_ticket_property_changed",
|
||||
"name": "HubSpot Ticket Property Changed",
|
||||
"description": "Trigger workflow when any property of a ticket is updated in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_ticket_restored",
|
||||
"name": "HubSpot Ticket Restored",
|
||||
"description": "Trigger workflow when a deleted ticket is restored in HubSpot"
|
||||
},
|
||||
{
|
||||
"id": "hubspot_webhook",
|
||||
"name": "HubSpot Webhook (All Events)",
|
||||
"description": "Trigger workflow on any HubSpot webhook event"
|
||||
}
|
||||
],
|
||||
"triggerCount": 18,
|
||||
"triggerCount": 27,
|
||||
"authType": "oauth",
|
||||
"category": "tools",
|
||||
"integrationType": "crm",
|
||||
@@ -10263,8 +10308,39 @@
|
||||
}
|
||||
],
|
||||
"operationCount": 35,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"triggers": [
|
||||
{
|
||||
"id": "salesforce_record_created",
|
||||
"name": "Salesforce Record Created",
|
||||
"description": "Trigger workflow when a Salesforce record is created"
|
||||
},
|
||||
{
|
||||
"id": "salesforce_record_updated",
|
||||
"name": "Salesforce Record Updated",
|
||||
"description": "Trigger workflow when a Salesforce record is updated"
|
||||
},
|
||||
{
|
||||
"id": "salesforce_record_deleted",
|
||||
"name": "Salesforce Record Deleted",
|
||||
"description": "Trigger workflow when a Salesforce record is deleted"
|
||||
},
|
||||
{
|
||||
"id": "salesforce_opportunity_stage_changed",
|
||||
"name": "Salesforce Opportunity Stage Changed",
|
||||
"description": "Trigger workflow when an opportunity stage changes"
|
||||
},
|
||||
{
|
||||
"id": "salesforce_case_status_changed",
|
||||
"name": "Salesforce Case Status Changed",
|
||||
"description": "Trigger workflow when a case status changes"
|
||||
},
|
||||
{
|
||||
"id": "salesforce_webhook",
|
||||
"name": "Salesforce Webhook (All Events)",
|
||||
"description": "Trigger workflow on any Salesforce webhook event"
|
||||
}
|
||||
],
|
||||
"triggerCount": 6,
|
||||
"authType": "oauth",
|
||||
"category": "tools",
|
||||
"integrationType": "crm",
|
||||
@@ -12856,8 +12932,39 @@
|
||||
}
|
||||
],
|
||||
"operationCount": 10,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"triggers": [
|
||||
{
|
||||
"id": "zoom_meeting_started",
|
||||
"name": "Meeting Started",
|
||||
"description": "Triggered when a Zoom meeting starts"
|
||||
},
|
||||
{
|
||||
"id": "zoom_meeting_ended",
|
||||
"name": "Meeting Ended",
|
||||
"description": "Triggered when a Zoom meeting ends"
|
||||
},
|
||||
{
|
||||
"id": "zoom_participant_joined",
|
||||
"name": "Participant Joined",
|
||||
"description": "Triggered when a participant joins a Zoom meeting"
|
||||
},
|
||||
{
|
||||
"id": "zoom_participant_left",
|
||||
"name": "Participant Left",
|
||||
"description": "Triggered when a participant leaves a Zoom meeting"
|
||||
},
|
||||
{
|
||||
"id": "zoom_recording_completed",
|
||||
"name": "Recording Completed",
|
||||
"description": "Triggered when a Zoom cloud recording is completed"
|
||||
},
|
||||
{
|
||||
"id": "zoom_webhook",
|
||||
"name": "Generic Webhook",
|
||||
"description": "Triggered on any Zoom webhook event"
|
||||
}
|
||||
],
|
||||
"triggerCount": 6,
|
||||
"authType": "oauth",
|
||||
"category": "tools",
|
||||
"integrationType": "communication",
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function parseWebhookBody(
|
||||
}
|
||||
|
||||
/** Providers that implement challenge/verification handling, checked before webhook lookup. */
|
||||
const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const
|
||||
const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp', 'zoom'] as const
|
||||
|
||||
export async function handleProviderChallenges(
|
||||
body: unknown,
|
||||
|
||||
@@ -38,6 +38,7 @@ import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
|
||||
import { vercelHandler } from '@/lib/webhooks/providers/vercel'
|
||||
import { webflowHandler } from '@/lib/webhooks/providers/webflow'
|
||||
import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp'
|
||||
import { zoomHandler } from '@/lib/webhooks/providers/zoom'
|
||||
|
||||
const logger = createLogger('WebhookProviderRegistry')
|
||||
|
||||
@@ -78,6 +79,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
vercel: vercelHandler,
|
||||
webflow: webflowHandler,
|
||||
whatsapp: whatsappHandler,
|
||||
zoom: zoomHandler,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
166
apps/sim/lib/webhooks/providers/zoom.ts
Normal file
166
apps/sim/lib/webhooks/providers/zoom.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import crypto from 'crypto'
|
||||
import { db, webhook } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type {
|
||||
AuthContext,
|
||||
EventMatchContext,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Zoom')
|
||||
|
||||
/**
|
||||
* Validate Zoom webhook signature using HMAC-SHA256.
|
||||
* Zoom sends `x-zm-signature` as `v0=<hex>` and `x-zm-request-timestamp`.
|
||||
* The message to hash is `v0:{timestamp}:{rawBody}`.
|
||||
*/
|
||||
function validateZoomSignature(
|
||||
secretToken: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
body: string
|
||||
): boolean {
|
||||
try {
|
||||
if (!secretToken || !signature || !timestamp || !body) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const requestSeconds = Number.parseInt(timestamp, 10)
|
||||
if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
const message = `v0:${timestamp}:${body}`
|
||||
const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex')
|
||||
const expectedSignature = `v0=${computedHash}`
|
||||
|
||||
return safeCompare(expectedSignature, signature)
|
||||
} catch (err) {
|
||||
logger.error('Zoom signature validation error', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const zoomHandler: WebhookProviderHandler = {
|
||||
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
|
||||
const secretToken = providerConfig.secretToken as string | undefined
|
||||
if (!secretToken) {
|
||||
logger.warn(
|
||||
`[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request`
|
||||
)
|
||||
return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 })
|
||||
}
|
||||
|
||||
const signature = request.headers.get('x-zm-signature')
|
||||
const timestamp = request.headers.get('x-zm-request-timestamp')
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`)
|
||||
return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 })
|
||||
}
|
||||
|
||||
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
|
||||
logger.warn(`[${requestId}] Zoom webhook signature verification failed`)
|
||||
return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) {
|
||||
const triggerId = providerConfig.triggerId as string | undefined
|
||||
const obj = body as Record<string, unknown>
|
||||
const event = obj.event as string | undefined
|
||||
|
||||
if (triggerId) {
|
||||
const { isZoomEventMatch } = await import('@/triggers/zoom/utils')
|
||||
if (!isZoomEventMatch(triggerId, event || '')) {
|
||||
logger.debug(
|
||||
`[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
|
||||
{
|
||||
webhookId: wh.id,
|
||||
workflowId: workflow.id,
|
||||
triggerId,
|
||||
receivedEvent: event,
|
||||
}
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle Zoom endpoint URL validation challenges.
|
||||
* Zoom sends an `endpoint.url_validation` event with a `plainToken` that must
|
||||
* be hashed with the app's secret token and returned alongside the original token.
|
||||
*/
|
||||
async handleChallenge(body: unknown, request: NextRequest, requestId: string, path: string) {
|
||||
const obj = body as Record<string, unknown> | null
|
||||
if (obj?.event !== 'endpoint.url_validation') {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = obj.payload as Record<string, unknown> | undefined
|
||||
const plainToken = payload?.plainToken as string | undefined
|
||||
if (!plainToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`)
|
||||
|
||||
// Look up the webhook record to get the secret token from providerConfig
|
||||
let secretToken = ''
|
||||
try {
|
||||
const webhooks = await db
|
||||
.select()
|
||||
.from(webhook)
|
||||
.where(
|
||||
and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true))
|
||||
)
|
||||
if (webhooks.length > 0) {
|
||||
const config = webhooks[0].providerConfig as Record<string, unknown> | null
|
||||
secretToken = (config?.secretToken as string) || ''
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!secretToken) {
|
||||
logger.warn(
|
||||
`[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Verify the challenge request's signature to prevent HMAC oracle attacks
|
||||
const signature = request.headers.get('x-zm-signature')
|
||||
const timestamp = request.headers.get('x-zm-request-timestamp')
|
||||
if (!signature || !timestamp) {
|
||||
logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`)
|
||||
return null
|
||||
}
|
||||
const rawBody = JSON.stringify(body)
|
||||
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
|
||||
logger.warn(`[${requestId}] Zoom challenge request failed signature verification`)
|
||||
return null
|
||||
}
|
||||
|
||||
const hashForValidate = crypto
|
||||
.createHmac('sha256', secretToken)
|
||||
.update(plainToken)
|
||||
.digest('hex')
|
||||
|
||||
return NextResponse.json({
|
||||
plainToken,
|
||||
encryptedToken: hashForValidate,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -243,6 +243,14 @@ import {
|
||||
webflowFormSubmissionTrigger,
|
||||
} from '@/triggers/webflow'
|
||||
import { whatsappWebhookTrigger } from '@/triggers/whatsapp'
|
||||
import {
|
||||
zoomMeetingEndedTrigger,
|
||||
zoomMeetingStartedTrigger,
|
||||
zoomParticipantJoinedTrigger,
|
||||
zoomParticipantLeftTrigger,
|
||||
zoomRecordingCompletedTrigger,
|
||||
zoomWebhookTrigger,
|
||||
} from '@/triggers/zoom'
|
||||
|
||||
export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
slack_webhook: slackWebhookTrigger,
|
||||
@@ -451,4 +459,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
intercom_contact_created: intercomContactCreatedTrigger,
|
||||
intercom_user_created: intercomUserCreatedTrigger,
|
||||
intercom_webhook: intercomWebhookTrigger,
|
||||
zoom_meeting_started: zoomMeetingStartedTrigger,
|
||||
zoom_meeting_ended: zoomMeetingEndedTrigger,
|
||||
zoom_participant_joined: zoomParticipantJoinedTrigger,
|
||||
zoom_participant_left: zoomParticipantLeftTrigger,
|
||||
zoom_recording_completed: zoomRecordingCompletedTrigger,
|
||||
zoom_webhook: zoomWebhookTrigger,
|
||||
}
|
||||
|
||||
6
apps/sim/triggers/zoom/index.ts
Normal file
6
apps/sim/triggers/zoom/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { zoomMeetingEndedTrigger } from './meeting_ended'
|
||||
export { zoomMeetingStartedTrigger } from './meeting_started'
|
||||
export { zoomParticipantJoinedTrigger } from './participant_joined'
|
||||
export { zoomParticipantLeftTrigger } from './participant_left'
|
||||
export { zoomRecordingCompletedTrigger } from './recording_completed'
|
||||
export { zoomWebhookTrigger } from './webhook'
|
||||
37
apps/sim/triggers/zoom/meeting_ended.ts
Normal file
37
apps/sim/triggers/zoom/meeting_ended.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildMeetingOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Zoom Meeting Ended Trigger
|
||||
*/
|
||||
export const zoomMeetingEndedTrigger: TriggerConfig = {
|
||||
id: 'zoom_meeting_ended',
|
||||
name: 'Zoom Meeting Ended',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow when a Zoom meeting ends',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_meeting_ended',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
setupInstructions: zoomSetupInstructions('meeting_ended'),
|
||||
extraFields: [zoomSecretTokenField('zoom_meeting_ended')],
|
||||
}),
|
||||
|
||||
outputs: buildMeetingOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
40
apps/sim/triggers/zoom/meeting_started.ts
Normal file
40
apps/sim/triggers/zoom/meeting_started.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildMeetingOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Zoom Meeting Started Trigger
|
||||
*
|
||||
* Primary trigger - includes the dropdown for selecting trigger type.
|
||||
*/
|
||||
export const zoomMeetingStartedTrigger: TriggerConfig = {
|
||||
id: 'zoom_meeting_started',
|
||||
name: 'Zoom Meeting Started',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow when a Zoom meeting starts',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_meeting_started',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
includeDropdown: true,
|
||||
setupInstructions: zoomSetupInstructions('meeting_started'),
|
||||
extraFields: [zoomSecretTokenField('zoom_meeting_started')],
|
||||
}),
|
||||
|
||||
outputs: buildMeetingOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
37
apps/sim/triggers/zoom/participant_joined.ts
Normal file
37
apps/sim/triggers/zoom/participant_joined.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildParticipantOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Zoom Participant Joined Trigger
|
||||
*/
|
||||
export const zoomParticipantJoinedTrigger: TriggerConfig = {
|
||||
id: 'zoom_participant_joined',
|
||||
name: 'Zoom Participant Joined',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow when a participant joins a Zoom meeting',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_participant_joined',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
setupInstructions: zoomSetupInstructions('participant_joined'),
|
||||
extraFields: [zoomSecretTokenField('zoom_participant_joined')],
|
||||
}),
|
||||
|
||||
outputs: buildParticipantOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
37
apps/sim/triggers/zoom/participant_left.ts
Normal file
37
apps/sim/triggers/zoom/participant_left.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildParticipantOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Zoom Participant Left Trigger
|
||||
*/
|
||||
export const zoomParticipantLeftTrigger: TriggerConfig = {
|
||||
id: 'zoom_participant_left',
|
||||
name: 'Zoom Participant Left',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow when a participant leaves a Zoom meeting',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_participant_left',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
setupInstructions: zoomSetupInstructions('participant_left'),
|
||||
extraFields: [zoomSecretTokenField('zoom_participant_left')],
|
||||
}),
|
||||
|
||||
outputs: buildParticipantOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
37
apps/sim/triggers/zoom/recording_completed.ts
Normal file
37
apps/sim/triggers/zoom/recording_completed.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildRecordingOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Zoom Recording Completed Trigger
|
||||
*/
|
||||
export const zoomRecordingCompletedTrigger: TriggerConfig = {
|
||||
id: 'zoom_recording_completed',
|
||||
name: 'Zoom Recording Completed',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow when a Zoom cloud recording is completed',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_recording_completed',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
setupInstructions: zoomSetupInstructions('recording_completed'),
|
||||
extraFields: [zoomSecretTokenField('zoom_recording_completed')],
|
||||
}),
|
||||
|
||||
outputs: buildRecordingOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
256
apps/sim/triggers/zoom/utils.ts
Normal file
256
apps/sim/triggers/zoom/utils.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { TriggerOutput } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Maps trigger IDs to the Zoom webhook event names they should match.
|
||||
*/
|
||||
const ZOOM_TRIGGER_EVENT_MAP: Record<string, string[]> = {
|
||||
zoom_meeting_started: ['meeting.started'],
|
||||
zoom_meeting_ended: ['meeting.ended'],
|
||||
zoom_participant_joined: ['meeting.participant_joined'],
|
||||
zoom_participant_left: ['meeting.participant_left'],
|
||||
zoom_recording_completed: ['recording.completed'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a Zoom webhook payload matches the configured trigger type.
|
||||
* Returns true for the generic `zoom_webhook` trigger (accepts all events).
|
||||
*/
|
||||
export function isZoomEventMatch(triggerId: string, event: string): boolean {
|
||||
if (triggerId === 'zoom_webhook') {
|
||||
return true
|
||||
}
|
||||
|
||||
const allowedEvents = ZOOM_TRIGGER_EVENT_MAP[triggerId]
|
||||
if (!allowedEvents) {
|
||||
return false
|
||||
}
|
||||
|
||||
return allowedEvents.includes(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown options for the Zoom trigger type selector.
|
||||
*/
|
||||
export const zoomTriggerOptions = [
|
||||
{ label: 'Meeting Started', id: 'zoom_meeting_started' },
|
||||
{ label: 'Meeting Ended', id: 'zoom_meeting_ended' },
|
||||
{ label: 'Participant Joined', id: 'zoom_participant_joined' },
|
||||
{ label: 'Participant Left', id: 'zoom_participant_left' },
|
||||
{ label: 'Recording Completed', id: 'zoom_recording_completed' },
|
||||
{ label: 'Generic Webhook (All Events)', id: 'zoom_webhook' },
|
||||
]
|
||||
|
||||
type ZoomEventType =
|
||||
| 'meeting_started'
|
||||
| 'meeting_ended'
|
||||
| 'participant_joined'
|
||||
| 'participant_left'
|
||||
| 'recording_completed'
|
||||
| 'generic'
|
||||
|
||||
/**
|
||||
* Generates setup instructions HTML for Zoom triggers.
|
||||
*/
|
||||
export function zoomSetupInstructions(eventType: ZoomEventType): string {
|
||||
const eventNames: Record<ZoomEventType, string> = {
|
||||
meeting_started: 'meeting.started',
|
||||
meeting_ended: 'meeting.ended',
|
||||
participant_joined: 'meeting.participant_joined',
|
||||
participant_left: 'meeting.participant_left',
|
||||
recording_completed: 'recording.completed',
|
||||
generic: 'your desired event type(s)',
|
||||
}
|
||||
|
||||
const instructions = [
|
||||
'Copy the <strong>Webhook URL</strong> above.',
|
||||
'Go to the <a href="https://marketplace.zoom.us/" target="_blank" rel="noopener noreferrer">Zoom Marketplace</a> and open your app (or create a new Webhook Only app).',
|
||||
"Copy the <strong>Secret Token</strong> from your Zoom app's <strong>Features</strong> page and paste it in the <strong>Secret Token</strong> field above.",
|
||||
'Click <strong>"Save Configuration"</strong> above to activate the trigger.',
|
||||
'Navigate to <strong>Features > Event Subscriptions</strong> and click <strong>Add Event Subscription</strong>.',
|
||||
'Enter a subscription name and paste the webhook URL into the <strong>Event notification endpoint URL</strong> field.',
|
||||
'Click <strong>Validate</strong> to verify the endpoint.',
|
||||
`Click <strong>Add Events</strong> and select the <strong>${eventNames[eventType]}</strong> event type.`,
|
||||
'Save the event subscription in Zoom.',
|
||||
]
|
||||
|
||||
return instructions
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the secret token field subBlock for a Zoom trigger.
|
||||
*/
|
||||
export function zoomSecretTokenField(triggerId: string): SubBlockConfig {
|
||||
return {
|
||||
id: 'secretToken',
|
||||
title: 'Secret Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Zoom app Secret Token',
|
||||
description:
|
||||
"Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification.",
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: triggerId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds outputs for meeting lifecycle events (started/ended).
|
||||
*/
|
||||
export function buildMeetingOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
event: {
|
||||
type: 'string',
|
||||
description: 'The webhook event type (e.g., meeting.started)',
|
||||
},
|
||||
event_ts: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp in milliseconds when the event occurred',
|
||||
},
|
||||
payload: {
|
||||
account_id: {
|
||||
type: 'string',
|
||||
description: 'Zoom account ID',
|
||||
},
|
||||
object: {
|
||||
type: 'object',
|
||||
description: 'Meeting details',
|
||||
properties: {
|
||||
id: { type: 'number', description: 'Meeting ID' },
|
||||
uuid: { type: 'string', description: 'Meeting UUID' },
|
||||
topic: { type: 'string', description: 'Meeting topic' },
|
||||
meeting_type: {
|
||||
type: 'number',
|
||||
description: 'Meeting type (1=instant, 2=scheduled, etc.)',
|
||||
},
|
||||
host_id: { type: 'string', description: 'Host user ID' },
|
||||
start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' },
|
||||
end_time: {
|
||||
type: 'string',
|
||||
description: 'Meeting end time (ISO 8601, present on meeting.ended)',
|
||||
},
|
||||
timezone: { type: 'string', description: 'Meeting timezone' },
|
||||
duration: { type: 'number', description: 'Meeting duration in minutes' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds outputs for participant events (joined/left).
|
||||
*/
|
||||
export function buildParticipantOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
event: {
|
||||
type: 'string',
|
||||
description: 'The webhook event type (e.g., meeting.participant_joined)',
|
||||
},
|
||||
event_ts: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp in milliseconds when the event occurred',
|
||||
},
|
||||
payload: {
|
||||
account_id: {
|
||||
type: 'string',
|
||||
description: 'Zoom account ID',
|
||||
},
|
||||
object: {
|
||||
type: 'object',
|
||||
description: 'Meeting details',
|
||||
properties: {
|
||||
id: { type: 'number', description: 'Meeting ID' },
|
||||
uuid: { type: 'string', description: 'Meeting UUID' },
|
||||
topic: { type: 'string', description: 'Meeting topic' },
|
||||
host_id: { type: 'string', description: 'Host user ID' },
|
||||
participant: {
|
||||
type: 'object',
|
||||
description: 'Participant details',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Participant user ID' },
|
||||
user_id: { type: 'string', description: 'Participant user ID (16-digit)' },
|
||||
user_name: { type: 'string', description: 'Participant display name' },
|
||||
email: { type: 'string', description: 'Participant email' },
|
||||
join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' },
|
||||
leave_time: {
|
||||
type: 'string',
|
||||
description: 'Time participant left (ISO 8601, present on participant_left)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds outputs for recording completed events.
|
||||
*/
|
||||
export function buildRecordingOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
event: {
|
||||
type: 'string',
|
||||
description: 'The webhook event type (recording.completed)',
|
||||
},
|
||||
event_ts: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp in milliseconds when the event occurred',
|
||||
},
|
||||
payload: {
|
||||
account_id: {
|
||||
type: 'string',
|
||||
description: 'Zoom account ID',
|
||||
},
|
||||
object: {
|
||||
type: 'object',
|
||||
description: 'Recording details',
|
||||
properties: {
|
||||
id: { type: 'number', description: 'Meeting ID' },
|
||||
uuid: { type: 'string', description: 'Meeting UUID' },
|
||||
topic: { type: 'string', description: 'Meeting topic' },
|
||||
host_id: { type: 'string', description: 'Host user ID' },
|
||||
host_email: { type: 'string', description: 'Host email' },
|
||||
start_time: { type: 'string', description: 'Recording start time (ISO 8601)' },
|
||||
duration: { type: 'number', description: 'Recording duration in minutes' },
|
||||
total_size: { type: 'number', description: 'Total recording size in bytes' },
|
||||
recording_count: { type: 'number', description: 'Number of recording files' },
|
||||
share_url: { type: 'string', description: 'URL to share the recording' },
|
||||
recording_files: {
|
||||
type: 'json',
|
||||
description: 'Array of recording file objects with download URLs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds outputs for generic webhook (any event type).
|
||||
*/
|
||||
export function buildGenericOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
event: {
|
||||
type: 'string',
|
||||
description: 'The webhook event type (e.g., meeting.started, recording.completed)',
|
||||
},
|
||||
event_ts: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp in milliseconds when the event occurred',
|
||||
},
|
||||
payload: {
|
||||
type: 'json',
|
||||
description: 'Complete webhook payload (structure varies by event type)',
|
||||
},
|
||||
}
|
||||
}
|
||||
37
apps/sim/triggers/zoom/webhook.ts
Normal file
37
apps/sim/triggers/zoom/webhook.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ZoomIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import {
|
||||
buildGenericOutputs,
|
||||
zoomSecretTokenField,
|
||||
zoomSetupInstructions,
|
||||
zoomTriggerOptions,
|
||||
} from '@/triggers/zoom/utils'
|
||||
|
||||
/**
|
||||
* Generic Zoom webhook trigger that accepts any event type.
|
||||
*/
|
||||
export const zoomWebhookTrigger: TriggerConfig = {
|
||||
id: 'zoom_webhook',
|
||||
name: 'Zoom Webhook (All Events)',
|
||||
provider: 'zoom',
|
||||
description: 'Trigger workflow on any Zoom webhook event',
|
||||
version: '1.0.0',
|
||||
icon: ZoomIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'zoom_webhook',
|
||||
triggerOptions: zoomTriggerOptions,
|
||||
setupInstructions: zoomSetupInstructions('generic'),
|
||||
extraFields: [zoomSecretTokenField('zoom_webhook')],
|
||||
}),
|
||||
|
||||
outputs: buildGenericOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user