diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 493950d5fde..59de55f8c26 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -2,13 +2,14 @@ import { GitLabIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { GitLabResponse } from '@/tools/gitlab/types' +import { getTrigger } from '@/triggers' export const GitLabBlock: BlockConfig = { type: 'gitlab', name: 'GitLab', description: 'Interact with GitLab projects, issues, merge requests, and pipelines', authMode: AuthMode.ApiKey, - triggerAllowed: false, + triggerAllowed: true, longDescription: 'Integrate GitLab into the workflow. Can manage projects, issues, merge requests, pipelines, and add comments. Supports all core GitLab DevOps operations.', docsLink: 'https://docs.sim.ai/integrations/gitlab', @@ -437,6 +438,12 @@ Return ONLY the commit message - no explanations, no extra text.`, ], }, }, + ...getTrigger('gitlab_push').subBlocks, + ...getTrigger('gitlab_merge_request').subBlocks, + ...getTrigger('gitlab_issue').subBlocks, + ...getTrigger('gitlab_pipeline').subBlocks, + ...getTrigger('gitlab_comment').subBlocks, + ...getTrigger('gitlab_webhook').subBlocks, ], tools: { access: [ @@ -746,6 +753,18 @@ Return ONLY the commit message - no explanations, no extra text.`, // Success indicator success: { type: 'boolean', description: 'Operation success status' }, }, + + triggers: { + enabled: true, + available: [ + 'gitlab_push', + 'gitlab_merge_request', + 'gitlab_issue', + 'gitlab_pipeline', + 'gitlab_comment', + 'gitlab_webhook', + ], + }, } export const GitLabBlockMeta = { diff --git a/apps/sim/blocks/blocks/pagerduty.ts b/apps/sim/blocks/blocks/pagerduty.ts index c47d27733bc..2ed88a7b96c 100644 --- a/apps/sim/blocks/blocks/pagerduty.ts +++ b/apps/sim/blocks/blocks/pagerduty.ts @@ -1,10 +1,12 @@ import { PagerDutyIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const PagerDutyBlock: BlockConfig = { type: 'pagerduty', name: 'PagerDuty', description: 'Manage incidents and on-call schedules with PagerDuty', + triggerAllowed: true, longDescription: 'Integrate PagerDuty into your workflow to list, create, and update incidents, add notes, list services, and check on-call schedules.', docsLink: 'https://docs.sim.ai/integrations/pagerduty', @@ -315,6 +317,12 @@ export const PagerDutyBlock: BlockConfig = { generationType: 'timestamp', }, }, + ...getTrigger('pagerduty_incident_triggered').subBlocks, + ...getTrigger('pagerduty_incident_acknowledged').subBlocks, + ...getTrigger('pagerduty_incident_resolved').subBlocks, + ...getTrigger('pagerduty_incident_escalated').subBlocks, + ...getTrigger('pagerduty_incident_reassigned').subBlocks, + ...getTrigger('pagerduty_webhook').subBlocks, ], tools: { @@ -481,6 +489,18 @@ export const PagerDutyBlock: BlockConfig = { description: 'Array of on-call entries (list_oncalls)', }, }, + + triggers: { + enabled: true, + available: [ + 'pagerduty_incident_triggered', + 'pagerduty_incident_acknowledged', + 'pagerduty_incident_resolved', + 'pagerduty_incident_escalated', + 'pagerduty_incident_reassigned', + 'pagerduty_webhook', + ], + }, } export const PagerDutyBlockMeta = { diff --git a/apps/sim/blocks/blocks/zendesk.ts b/apps/sim/blocks/blocks/zendesk.ts index 063191ec227..9d51552cf82 100644 --- a/apps/sim/blocks/blocks/zendesk.ts +++ b/apps/sim/blocks/blocks/zendesk.ts @@ -1,11 +1,13 @@ import { ZendeskIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const ZendeskBlock: BlockConfig = { type: 'zendesk', name: 'Zendesk', description: 'Manage support tickets, users, and organizations in Zendesk', + triggerAllowed: true, longDescription: 'Integrate Zendesk into the workflow. Can get tickets, get ticket, create ticket, create tickets bulk, update ticket, update tickets bulk, delete ticket, merge tickets, get users, get user, get current user, search users, create user, create users bulk, update user, update users bulk, delete user, get organizations, get organization, autocomplete organizations, create organization, create organizations bulk, update organization, delete organization, search, search count.', docsLink: 'https://docs.sim.ai/integrations/zendesk', @@ -529,6 +531,11 @@ Return ONLY the search query - no explanations.`, }, mode: 'advanced', }, + ...getTrigger('zendesk_ticket_created').subBlocks, + ...getTrigger('zendesk_ticket_status_changed').subBlocks, + ...getTrigger('zendesk_ticket_comment_added').subBlocks, + ...getTrigger('zendesk_ticket_priority_changed').subBlocks, + ...getTrigger('zendesk_webhook').subBlocks, ], tools: { access: [ @@ -695,6 +702,17 @@ Return ONLY the search query - no explanations.`, // Metadata (shared across all operations) metadata: { type: 'json', description: 'Operation metadata including operation type' }, }, + + triggers: { + enabled: true, + available: [ + 'zendesk_ticket_created', + 'zendesk_ticket_status_changed', + 'zendesk_ticket_comment_added', + 'zendesk_ticket_priority_changed', + 'zendesk_webhook', + ], + }, } export const ZendeskBlockMeta = { diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index fc75bfb0bd1..c1696fc7c2c 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -493,6 +493,7 @@ export class IdempotencyService { normalizedHeaders?.['x-webhook-id'] || normalizedHeaders?.['x-shopify-webhook-id'] || normalizedHeaders?.['x-github-delivery'] || + normalizedHeaders?.['x-gitlab-event-uuid'] || normalizedHeaders?.['x-event-id'] || normalizedHeaders?.['x-teams-notification-id'] || normalizedHeaders?.['svix-id'] || diff --git a/apps/sim/lib/webhooks/providers/gitlab.ts b/apps/sim/lib/webhooks/providers/gitlab.ts new file mode 100644 index 00000000000..3f6ffcbf12e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gitlab.ts @@ -0,0 +1,189 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { generateId } from '@sim/utils/id' +import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:GitLab') + +const GITLAB_API_BASE = 'https://gitlab.com/api/v4' + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +function gitlabProjectHooksUrl(projectId: string): string { + return `${GITLAB_API_BASE}/projects/${encodeURIComponent(projectId)}/hooks` +} + +/** + * Best-effort cleanup that deletes any project hook pointing at `url`. Used to + * avoid orphaning a hook when the create response can't be parsed for its id. + */ +async function cleanupGitLabHookByUrl( + projectId: string, + accessToken: string, + url: string +): Promise { + const res = await fetch(gitlabProjectHooksUrl(projectId), { + headers: { 'PRIVATE-TOKEN': accessToken }, + }).catch(() => null) + if (!res || !res.ok) return + + const hooks = (await res.json().catch(() => null)) as Array<{ id?: number; url?: string }> | null + if (!Array.isArray(hooks)) return + + await Promise.all( + hooks + .filter((hook) => hook.url === url && hook.id != null) + .map((hook) => + fetch(`${gitlabProjectHooksUrl(projectId)}/${hook.id}`, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': accessToken }, + }).catch(() => null) + ) + ) +} + +export const gitlabHandler: WebhookProviderHandler = { + /** + * GitLab echoes the configured "Secret token" verbatim in the `X-Gitlab-Token` + * header (plain equality, not an HMAC). The secret is generated during + * auto-registration, so a missing secret means misconfiguration — fail closed. + */ + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + logger.warn(`[${requestId}] GitLab webhook secret not configured`) + return new NextResponse('Unauthorized - Missing GitLab webhook secret', { status: 401 }) + } + + const token = request.headers.get('X-Gitlab-Token') + if (!token) { + logger.warn(`[${requestId}] GitLab webhook missing X-Gitlab-Token header`) + return new NextResponse('Unauthorized - Missing GitLab token', { status: 401 }) + } + + if (!safeCompare(token, secret)) { + logger.warn(`[${requestId}] GitLab token verification failed`) + return new NextResponse('Unauthorized - Invalid GitLab token', { status: 401 }) + } + + return null + }, + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'gitlab_webhook') return true + + const objectKind = asRecord(body).object_kind as string | undefined + + const { isGitLabEventMatch } = await import('@/triggers/gitlab/utils') + if (!isGitLabEventMatch(triggerId, objectKind || '')) { + logger.debug( + `[${requestId}] GitLab event '${objectKind}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body, headers }: FormatInputContext): Promise { + const b = asRecord(body) + const eventType = headers['x-gitlab-event'] || '' + const ref = (b.ref as string) || '' + const branch = ref.replace('refs/heads/', '') + return { + input: { ...b, event_type: eventType, branch }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const accessToken = config.accessToken as string | undefined + const projectId = config.projectId as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!accessToken) + throw new Error('GitLab Personal Access Token is required to create the webhook.') + if (!projectId) throw new Error('GitLab Project ID is required to create the webhook.') + + const { getGitLabEventFlags } = await import('@/triggers/gitlab/utils') + const secretToken = generateId() + const res = await fetch(gitlabProjectHooksUrl(projectId), { + method: 'POST', + headers: { 'PRIVATE-TOKEN': accessToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: getNotificationUrl(ctx.webhook), + token: secretToken, + enable_ssl_verification: true, + ...getGitLabEventFlags(triggerId ?? 'gitlab_webhook'), + }), + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create GitLab webhook (${res.status})`, { detail }) + if (res.status === 401) + throw new Error( + 'GitLab authentication failed. Verify your Personal Access Token has the api scope.' + ) + if (res.status === 403) + throw new Error( + 'GitLab access denied. You need the Maintainer or Owner role on the project.' + ) + if (res.status === 404) throw new Error('GitLab project not found. Verify the Project ID.') + throw new Error(`Failed to create GitLab webhook: ${res.status}`) + } + + const created = (await res.json().catch(() => ({}))) as { id?: number | string } + if (created.id === undefined || created.id === null) { + // The hook was created but we can't read its id — delete it by URL so it + // is not orphaned in GitLab. + await cleanupGitLabHookByUrl(projectId, accessToken, getNotificationUrl(ctx.webhook)) + throw new Error('GitLab webhook created but no hook ID was returned.') + } + + logger.info(`[${ctx.requestId}] Created GitLab webhook ${created.id} for project ${projectId}`) + return { providerConfigUpdates: { externalId: String(created.id), webhookSecret: secretToken } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const accessToken = config.accessToken as string | undefined + const projectId = config.projectId as string | undefined + const externalId = config.externalId as string | undefined + + if (!accessToken || !projectId || !externalId) { + if (ctx.strict) throw new Error('Missing GitLab credentials or hook ID for webhook deletion.') + logger.warn( + `[${ctx.requestId}] Skipping GitLab webhook cleanup — missing token, project, or hook ID` + ) + return + } + + const res = await fetch(`${gitlabProjectHooksUrl(projectId)}/${externalId}`, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': accessToken }, + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete GitLab webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete GitLab webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted GitLab webhook ${externalId}`) + }, +} diff --git a/apps/sim/lib/webhooks/providers/pagerduty.ts b/apps/sim/lib/webhooks/providers/pagerduty.ts new file mode 100644 index 00000000000..f16124eaa1f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/pagerduty.ts @@ -0,0 +1,226 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:PagerDuty') + +const PAGERDUTY_API_BASE = 'https://api.pagerduty.com' + +/** Shared headers for PagerDuty REST API calls (the v2 Accept header is required). */ +function pagerdutyHeaders(apiKey: string): Record { + return { + Authorization: `Token token=${apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/vnd.pagerduty+json;version=2', + } +} + +/** + * PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the + * `X-PagerDuty-Signature` header as one or more comma-separated `v1=` + * values (multiple appear during signing-secret rotation). The delivery is + * valid when our computed signature matches any of them. + */ +function validatePagerDutySignature(secret: string, signature: string, body: string): boolean { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return signature + .split(',') + .map((part) => part.trim()) + .filter((part) => part.startsWith('v1=')) + .some((part) => safeCompare(part.slice(3), computed)) +} + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +/** + * Best-effort cleanup of a webhook subscription after a failed setup. Deletes by + * id when known, otherwise finds the subscription pointing at `url` and deletes + * it, so a created subscription is never orphaned in PagerDuty. + */ +async function cleanupPagerDutySubscription( + apiKey: string, + url: string, + subscriptionId?: string +): Promise { + let id = subscriptionId + if (!id) { + const listRes = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, { + headers: pagerdutyHeaders(apiKey), + }).catch(() => null) + if (!listRes || !listRes.ok) return + const body = (await listRes.json().catch(() => null)) as { + webhook_subscriptions?: Array<{ id?: string; delivery_method?: { url?: string } }> + } | null + id = body?.webhook_subscriptions?.find((sub) => sub.delivery_method?.url === url)?.id + } + if (!id) return + await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${id}`, { + method: 'DELETE', + headers: pagerdutyHeaders(apiKey), + }).catch(() => null) +} + +function referenceSummary( + value: unknown +): { id?: unknown; summary?: unknown; html_url?: unknown } | null { + if (!value || typeof value !== 'object') return null + const ref = value as Record + return { id: ref.id, summary: ref.summary, html_url: ref.html_url } +} + +export const pagerdutyHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-PagerDuty-Signature', + validateFn: validatePagerDutySignature, + providerLabel: 'PagerDuty', + // The signing secret is captured during auto-registration, so a missing + // secret means misconfiguration — fail closed rather than skip verification. + requireSecret: true, + }), + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'pagerduty_webhook') return true + + const event = asRecord(asRecord(body).event) + const eventType = event.event_type as string | undefined + + const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils') + if (!isPagerDutyEventMatch(triggerId, eventType || '')) { + logger.debug( + `[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const event = asRecord(asRecord(body).event) + const data = asRecord(event.data) + const priority = referenceSummary(data.priority) + + return { + input: { + event_id: event.id, + event_type: event.event_type, + occurred_at: event.occurred_at, + agent: event.agent ?? null, + incident: { + id: data.id, + number: data.number, + title: data.title, + status: data.status, + urgency: data.urgency, + html_url: data.html_url, + created_at: data.created_at, + priority: priority?.summary ?? null, + service: referenceSummary(data.service), + escalation_policy: referenceSummary(data.escalation_policy), + assignees: Array.isArray(data.assignees) ? data.assignees : [], + }, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const event = asRecord(asRecord(body).event) + return (event.id as string | undefined) || null + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!apiKey) + throw new Error('PagerDuty API Key is required to create the webhook subscription.') + + const { getPagerDutyEvents } = await import('@/triggers/pagerduty/utils') + const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, { + method: 'POST', + headers: pagerdutyHeaders(apiKey), + body: JSON.stringify({ + webhook_subscription: { + type: 'webhook_subscription', + delivery_method: { type: 'http_delivery_method', url: getNotificationUrl(ctx.webhook) }, + events: getPagerDutyEvents(triggerId ?? 'pagerduty_webhook'), + filter: { type: 'account_reference' }, + }, + }), + }) + + if (!res.ok) { + const detail = await res.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, { + detail, + }) + if (res.status === 401) + throw new Error('PagerDuty authentication failed. Verify your REST API key.') + if (res.status === 403) + throw new Error('PagerDuty access denied. The API key must have read/write access.') + throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`) + } + + const created = asRecord((await res.json().catch(() => ({}))) as unknown) + const subscription = asRecord(created.webhook_subscription) + const externalId = subscription.id as string | undefined + const secret = asRecord(subscription.delivery_method).secret as string | undefined + + // The subscription exists once PagerDuty returns success; if it is missing + // its id or signing secret, delete it so it is not orphaned, then fail. + if (!externalId || !secret) { + await cleanupPagerDutySubscription(apiKey, getNotificationUrl(ctx.webhook), externalId) + if (!externalId) { + throw new Error('PagerDuty webhook created but no subscription ID was returned.') + } + throw new Error('PagerDuty webhook created but no signing secret was returned on creation.') + } + + logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`) + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey || !externalId) { + if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.') + logger.warn( + `[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID` + ) + return + } + + const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${externalId}`, { + method: 'DELETE', + headers: pagerdutyHeaders(apiKey), + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`) + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 487cc851727..bbb464ae35c 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -13,6 +13,7 @@ import { fathomHandler } from '@/lib/webhooks/providers/fathom' import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' +import { gitlabHandler } from '@/lib/webhooks/providers/gitlab' import { gmailHandler } from '@/lib/webhooks/providers/gmail' import { gongHandler } from '@/lib/webhooks/providers/gong' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' @@ -29,6 +30,7 @@ import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' import { mondayHandler } from '@/lib/webhooks/providers/monday' import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty' import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' import { salesforceHandler } from '@/lib/webhooks/providers/salesforce' @@ -46,6 +48,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 { zendeskHandler } from '@/lib/webhooks/providers/zendesk' import { zoomHandler } from '@/lib/webhooks/providers/zoom' const logger = createLogger('WebhookProviderRegistry') @@ -64,6 +67,7 @@ const PROVIDER_HANDLERS: Record = { generic: genericHandler, gmail: gmailHandler, github: githubHandler, + gitlab: gitlabHandler, gong: gongHandler, google_forms: googleFormsHandler, fathom: fathomHandler, @@ -81,6 +85,7 @@ const PROVIDER_HANDLERS: Record = { 'microsoft-teams': microsoftTeamsHandler, notion: notionHandler, outlook: outlookHandler, + pagerduty: pagerdutyHandler, rss: rssHandler, salesforce: salesforceHandler, sendblue: sendblueHandler, @@ -95,6 +100,7 @@ const PROVIDER_HANDLERS: Record = { vercel: vercelHandler, webflow: webflowHandler, whatsapp: whatsappHandler, + zendesk: zendeskHandler, zoom: zoomHandler, } diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts index dff3db7ce1e..43ff2b45fed 100644 --- a/apps/sim/lib/webhooks/providers/utils.ts +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -11,6 +11,12 @@ interface HmacVerifierOptions { headerName: string validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise providerLabel: string + /** + * When true, reject (401) if no secret is configured instead of skipping + * verification. Use for providers where the secret is always present (e.g. + * auto-registered webhooks) so a missing secret fails closed. + */ + requireSecret?: boolean } /** @@ -22,6 +28,7 @@ export function createHmacVerifier({ headerName, validateFn, providerLabel, + requireSecret = false, }: HmacVerifierOptions) { return async ({ request, @@ -31,6 +38,12 @@ export function createHmacVerifier({ }: AuthContext): Promise => { const secret = providerConfig[configKey] as string | undefined if (!secret) { + if (requireSecret) { + logger.warn(`[${requestId}] ${providerLabel} webhook secret not configured`) + return new NextResponse(`Unauthorized - Missing ${providerLabel} webhook secret`, { + status: 401, + }) + } return null } diff --git a/apps/sim/lib/webhooks/providers/zendesk.ts b/apps/sim/lib/webhooks/providers/zendesk.ts new file mode 100644 index 00000000000..54c50b914b0 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zendesk.ts @@ -0,0 +1,264 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Zendesk') + +function asRecord(value: unknown): Record { + return (value as Record) || {} +} + +/** Zendesk API base for a subdomain. */ +function zendeskApiBase(subdomain: string): string { + return `https://${subdomain}.zendesk.com/api/v2` +} + +/** Basic auth header for the Zendesk API-token scheme (`email/token:apiToken`). */ +function zendeskAuthHeader(email: string, apiToken: string): string { + return `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}` +} + +/** Best-effort delete used to avoid orphaning a webhook when post-create setup fails. */ +async function deleteZendeskWebhookQuietly( + apiBase: string, + authHeader: string, + webhookId: string +): Promise { + await fetch(`${apiBase}/webhooks/${webhookId}`, { + method: 'DELETE', + headers: { Authorization: authHeader }, + }).catch(() => {}) +} + +/** Maximum allowed clock skew (5 minutes) between Zendesk's signed timestamp and now, per Zendesk docs. */ +const ZENDESK_TIMESTAMP_MAX_SKEW_MS = 5 * 60 * 1000 + +/** + * Verify the signed timestamp is recent to prevent replay of captured deliveries. + * Zendesk sends `X-Zendesk-Webhook-Signature-Timestamp` as an ISO-8601 string + * (e.g. `2025-01-24T15:30:00.000Z`), so it is parsed with `Date.parse`. + */ +function isZendeskTimestampFresh(timestamp: string): boolean { + const signedAt = Date.parse(timestamp) + if (Number.isNaN(signedAt)) return false + return Math.abs(Date.now() - signedAt) <= ZENDESK_TIMESTAMP_MAX_SKEW_MS +} + +/** + * Zendesk signs `timestamp + rawBody` (no separator) with HMAC-SHA256 keyed by + * the webhook's signing secret, then base64-encodes it into + * `X-Zendesk-Webhook-Signature`. The timestamp is sent in a separate header. + */ +function validateZendeskSignature( + secret: string, + signature: string, + timestamp: string, + body: string +): boolean { + if (!secret || !signature || !timestamp) return false + const computed = crypto + .createHmac('sha256', secret) + .update(timestamp + body, 'utf8') + .digest('base64') + return safeCompare(computed, signature) +} + +export const zendeskHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + // The signing secret is fetched during auto-registration, so a missing + // secret means misconfiguration — fail closed rather than skip. + logger.warn(`[${requestId}] Zendesk webhook secret not configured`) + return new NextResponse('Unauthorized - Missing Zendesk webhook secret', { status: 401 }) + } + + const signature = request.headers.get('X-Zendesk-Webhook-Signature') + const timestamp = request.headers.get('X-Zendesk-Webhook-Signature-Timestamp') + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Zendesk webhook missing signature headers`) + return new NextResponse('Unauthorized - Missing Zendesk signature', { status: 401 }) + } + + if (!isZendeskTimestampFresh(timestamp)) { + logger.warn(`[${requestId}] Zendesk webhook timestamp outside the allowed window`, { + timestamp, + }) + return new NextResponse('Unauthorized - Stale Zendesk timestamp', { status: 401 }) + } + + if (!validateZendeskSignature(secret, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Zendesk signature verification failed`) + return new NextResponse('Unauthorized - Invalid Zendesk signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'zendesk_webhook') return true + + const eventType = asRecord(body).type as string | undefined + + const { isZendeskEventMatch } = await import('@/triggers/zendesk/utils') + if (!isZendeskEventMatch(triggerId, eventType || '')) { + logger.debug( + `[${requestId}] Zendesk event '${eventType}' does not match trigger ${triggerId}, skipping` + ) + return false + } + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = asRecord(body) + const detail = asRecord(b.detail) + const via = asRecord(detail.via) + + return { + input: { + event_id: b.id, + event_type: b.type, + time: b.time, + account_id: b.account_id, + ticket: { + id: detail.id, + subject: detail.subject, + status: detail.status, + priority: detail.priority, + ticket_type: detail.type, + description: detail.description, + requester_id: detail.requester_id, + assignee_id: detail.assignee_id, + group_id: detail.group_id, + organization_id: detail.organization_id, + tags: Array.isArray(detail.tags) ? detail.tags : [], + via_channel: via.channel, + is_public: detail.is_public, + created_at: detail.created_at, + updated_at: detail.updated_at, + }, + event: b.event ?? null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + return (asRecord(body).id as string | undefined) || null + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const subdomain = config.subdomain as string | undefined + const email = config.email as string | undefined + const apiToken = config.apiToken as string | undefined + const triggerId = config.triggerId as string | undefined + + if (!subdomain) throw new Error('Zendesk subdomain is required to create the webhook.') + if (!email) throw new Error('Zendesk admin email is required to create the webhook.') + if (!apiToken) throw new Error('Zendesk API token is required to create the webhook.') + + const { getZendeskSubscriptions } = await import('@/triggers/zendesk/utils') + const apiBase = zendeskApiBase(subdomain) + const authHeader = zendeskAuthHeader(email, apiToken) + + const createRes = await fetch(`${apiBase}/webhooks`, { + method: 'POST', + headers: { Authorization: authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + webhook: { + name: `Sim webhook (${ctx.webhook.id})`, + endpoint: getNotificationUrl(ctx.webhook), + http_method: 'POST', + request_format: 'json', + status: 'active', + subscriptions: getZendeskSubscriptions(triggerId ?? 'zendesk_webhook'), + }, + }), + }) + + if (!createRes.ok) { + const detail = await createRes.text().catch(() => '') + logger.error(`[${ctx.requestId}] Failed to create Zendesk webhook (${createRes.status})`, { + detail, + }) + if (createRes.status === 401 || createRes.status === 403) { + throw new Error( + 'Zendesk authentication failed. Verify the subdomain, admin email, and API token.' + ) + } + throw new Error(`Failed to create Zendesk webhook: ${createRes.status}`) + } + + const created = asRecord((await createRes.json().catch(() => ({}))) as unknown) + const externalId = asRecord(created.webhook).id as string | undefined + if (!externalId) throw new Error('Zendesk webhook created but no webhook ID was returned.') + + const secretRes = await fetch(`${apiBase}/webhooks/${externalId}/signing_secret`, { + headers: { Authorization: authHeader }, + }) + if (!secretRes.ok) { + const detail = await secretRes.text().catch(() => '') + logger.error( + `[${ctx.requestId}] Created Zendesk webhook ${externalId} but failed to fetch signing secret (${secretRes.status})`, + { detail } + ) + // Avoid leaving an orphaned webhook in Zendesk when secret retrieval fails. + await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) + throw new Error(`Failed to fetch Zendesk signing secret: ${secretRes.status}`) + } + + const secretBody = asRecord((await secretRes.json().catch(() => ({}))) as unknown) + const secret = asRecord(secretBody.signing_secret).secret as string | undefined + if (!secret) { + await deleteZendeskWebhookQuietly(apiBase, authHeader, externalId) + throw new Error('Zendesk did not return a signing secret for the webhook.') + } + + logger.info(`[${ctx.requestId}] Created Zendesk webhook ${externalId}`) + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const subdomain = config.subdomain as string | undefined + const email = config.email as string | undefined + const apiToken = config.apiToken as string | undefined + const externalId = config.externalId as string | undefined + + if (!subdomain || !email || !apiToken || !externalId) { + if (ctx.strict) throw new Error('Missing Zendesk credentials or webhook ID for deletion.') + logger.warn( + `[${ctx.requestId}] Skipping Zendesk webhook cleanup — missing credentials or webhook ID` + ) + return + } + + const res = await fetch(`${zendeskApiBase(subdomain)}/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: zendeskAuthHeader(email, apiToken) }, + }) + + if (!res.ok && res.status !== 404) { + if (ctx.strict) throw new Error(`Failed to delete Zendesk webhook: ${res.status}`) + logger.warn( + `[${ctx.requestId}] Failed to delete Zendesk webhook ${externalId} (non-fatal): ${res.status}` + ) + return + } + logger.info(`[${ctx.requestId}] Deleted Zendesk webhook ${externalId}`) + }, +} diff --git a/apps/sim/triggers/gitlab/comment.ts b/apps/sim/triggers/gitlab/comment.ts new file mode 100644 index 00000000000..1435f370ab4 --- /dev/null +++ b/apps/sim/triggers/gitlab/comment.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabCommentOutputs, + buildGitLabExtraFields, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabCommentTrigger: TriggerConfig = { + id: 'gitlab_comment', + name: 'GitLab Comment', + provider: 'gitlab', + description: 'Trigger workflow when a comment is added on a commit, merge request, or issue', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_comment', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Comment'), + extraFields: buildGitLabExtraFields('gitlab_comment'), + }), + outputs: buildGitLabCommentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Note Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/index.ts b/apps/sim/triggers/gitlab/index.ts new file mode 100644 index 00000000000..f6e75320a7a --- /dev/null +++ b/apps/sim/triggers/gitlab/index.ts @@ -0,0 +1,6 @@ +export { gitlabCommentTrigger } from './comment' +export { gitlabIssueTrigger } from './issue' +export { gitlabMergeRequestTrigger } from './merge_request' +export { gitlabPipelineTrigger } from './pipeline' +export { gitlabPushTrigger } from './push' +export { gitlabWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/gitlab/issue.ts b/apps/sim/triggers/gitlab/issue.ts new file mode 100644 index 00000000000..a9fd802ba2a --- /dev/null +++ b/apps/sim/triggers/gitlab/issue.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabIssueOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabIssueTrigger: TriggerConfig = { + id: 'gitlab_issue', + name: 'GitLab Issue', + provider: 'gitlab', + description: 'Trigger workflow when an issue is opened, updated, or closed in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_issue', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Issue'), + extraFields: buildGitLabExtraFields('gitlab_issue'), + }), + outputs: buildGitLabIssueOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Issue Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/merge_request.ts b/apps/sim/triggers/gitlab/merge_request.ts new file mode 100644 index 00000000000..cdba6a4c178 --- /dev/null +++ b/apps/sim/triggers/gitlab/merge_request.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabMergeRequestOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabMergeRequestTrigger: TriggerConfig = { + id: 'gitlab_merge_request', + name: 'GitLab Merge Request', + provider: 'gitlab', + description: 'Trigger workflow when a merge request is opened, updated, or merged in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_merge_request', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Merge Request'), + extraFields: buildGitLabExtraFields('gitlab_merge_request'), + }), + outputs: buildGitLabMergeRequestOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Merge Request Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/pipeline.ts b/apps/sim/triggers/gitlab/pipeline.ts new file mode 100644 index 00000000000..2999bf599cc --- /dev/null +++ b/apps/sim/triggers/gitlab/pipeline.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabPipelineOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabPipelineTrigger: TriggerConfig = { + id: 'gitlab_pipeline', + name: 'GitLab Pipeline', + provider: 'gitlab', + description: 'Trigger workflow when a pipeline status changes in GitLab', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_pipeline', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('Pipeline'), + extraFields: buildGitLabExtraFields('gitlab_pipeline'), + }), + outputs: buildGitLabPipelineOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Pipeline Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/push.ts b/apps/sim/triggers/gitlab/push.ts new file mode 100644 index 00000000000..1a93bdf2791 --- /dev/null +++ b/apps/sim/triggers/gitlab/push.ts @@ -0,0 +1,34 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabPushOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabPushTrigger: TriggerConfig = { + id: 'gitlab_push', + name: 'GitLab Push', + provider: 'gitlab', + description: 'Trigger workflow when commits are pushed to a GitLab project', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_push', + triggerOptions: gitlabTriggerOptions, + includeDropdown: true, + setupInstructions: gitlabSetupInstructions('Push'), + extraFields: buildGitLabExtraFields('gitlab_push'), + }), + outputs: buildGitLabPushOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Push Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/gitlab/utils.ts b/apps/sim/triggers/gitlab/utils.ts new file mode 100644 index 00000000000..6f7848ad2fe --- /dev/null +++ b/apps/sim/triggers/gitlab/utils.ts @@ -0,0 +1,240 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all GitLab triggers + */ +export const gitlabTriggerOptions = [ + { label: 'Push', id: 'gitlab_push' }, + { label: 'Merge Request', id: 'gitlab_merge_request' }, + { label: 'Issue', id: 'gitlab_issue' }, + { label: 'Pipeline', id: 'gitlab_pipeline' }, + { label: 'Comment', id: 'gitlab_comment' }, + { label: 'All Events', id: 'gitlab_webhook' }, +] + +/** + * Maps each GitLab trigger to the payload `object_kind` it listens for. + * `gitlab_webhook` is intentionally absent — it matches every event. + */ +const TRIGGER_OBJECT_KINDS: Record = { + gitlab_push: 'push', + gitlab_merge_request: 'merge_request', + gitlab_issue: 'issue', + gitlab_pipeline: 'pipeline', + gitlab_comment: 'note', +} + +/** + * Boolean event flags sent to the GitLab project-hooks API, keyed by trigger. + * `gitlab_webhook` subscribes to every supported event. + */ +const ALL_EVENT_FLAGS = { + push_events: true, + merge_requests_events: true, + issues_events: true, + pipeline_events: true, + note_events: true, + tag_push_events: true, +} as const + +// Tag pushes (object_kind 'tag_push') only flow through the all-events trigger; +// there is no dedicated single-event trigger for them. A future "GitLab Tag Push" +// trigger would need its own object_kind mapping in TRIGGER_OBJECT_KINDS above. +const TRIGGER_EVENT_FLAGS: Record> = { + gitlab_push: { push_events: true }, + gitlab_merge_request: { merge_requests_events: true }, + gitlab_issue: { issues_events: true }, + gitlab_pipeline: { pipeline_events: true }, + gitlab_comment: { note_events: true }, +} + +/** + * Returns the GitLab hook event flags to enable for a given trigger. + */ +export function getGitLabEventFlags(triggerId: string): Record { + return TRIGGER_EVENT_FLAGS[triggerId] ?? { ...ALL_EVENT_FLAGS } +} + +/** + * Generate setup instructions for a specific GitLab webhook event. The webhook + * is created automatically on deploy, so the user only supplies credentials. + */ +export function gitlabSetupInstructions(eventLabel: string): string { + const instructions = [ + 'Create a Personal Access Token with the api scope under GitLab > Settings > Access Tokens.', + 'Enter the token and your Project ID (numeric ID or group/project path) above.', + `Deploy the workflow — Sim creates the webhook in GitLab automatically and starts listening for ${eventLabel} events.`, + 'Undeploying the workflow removes the webhook from GitLab.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Credentials Sim uses to create and delete the GitLab project webhook. + */ +export function buildGitLabExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'accessToken', + title: 'Personal Access Token', + type: 'short-input', + placeholder: 'GitLab PAT with the api scope', + description: + 'Used to create the webhook in your project. Requires the Maintainer or Owner role.', + password: true, + paramVisibility: 'user-only', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'projectId', + title: 'Project ID', + type: 'short-input', + placeholder: 'Numeric ID or group/project path', + description: 'The GitLab project to register the webhook on.', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +const projectOutputs = { + id: { type: 'number', description: 'Project ID' }, + name: { type: 'string', description: 'Project name' }, + web_url: { type: 'string', description: 'Project web URL' }, + path_with_namespace: { type: 'string', description: 'Full path (namespace/project)' }, +} as const + +const actorUserOutputs = { + id: { type: 'number', description: 'User ID' }, + name: { type: 'string', description: 'User display name' }, + username: { type: 'string', description: 'Username' }, +} as const + +export function buildGitLabPushOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (push)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + ref: { type: 'string', description: 'Git ref that was pushed (e.g. refs/heads/main)' }, + branch: { type: 'string', description: 'Branch name derived from ref' }, + before: { type: 'string', description: 'SHA before the push' }, + after: { type: 'string', description: 'SHA after the push' }, + checkout_sha: { type: 'string', description: 'SHA of the most recent commit' }, + user_username: { type: 'string', description: 'Username of the pusher' }, + user_name: { type: 'string', description: 'Display name of the pusher' }, + user_email: { type: 'string', description: 'Email of the pusher' }, + total_commits_count: { type: 'number', description: 'Number of commits in the push' }, + project: projectOutputs, + commits: { type: 'json', description: 'Array of commit objects included in this push' }, + } +} + +export function buildGitLabMergeRequestOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (merge_request)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Global merge request ID' }, + iid: { type: 'number', description: 'Project-scoped merge request number' }, + title: { type: 'string', description: 'Merge request title' }, + state: { type: 'string', description: 'State (opened, closed, merged, locked)' }, + action: { type: 'string', description: 'Action (open, close, reopen, update, merge, etc.)' }, + source_branch: { type: 'string', description: 'Source branch' }, + target_branch: { type: 'string', description: 'Target branch' }, + merge_status: { type: 'string', description: 'Merge status' }, + draft: { type: 'boolean', description: 'Whether the merge request is a draft' }, + url: { type: 'string', description: 'Merge request URL' }, + }, + } +} + +export function buildGitLabIssueOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (issue)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Global issue ID' }, + iid: { type: 'number', description: 'Project-scoped issue number' }, + title: { type: 'string', description: 'Issue title' }, + state: { type: 'string', description: 'State (opened, closed)' }, + action: { type: 'string', description: 'Action (open, close, reopen, update)' }, + description: { type: 'string', description: 'Issue description' }, + confidential: { type: 'boolean', description: 'Whether the issue is confidential' }, + url: { type: 'string', description: 'Issue URL' }, + }, + } +} + +export function buildGitLabPipelineOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (pipeline)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Pipeline ID' }, + status: { type: 'string', description: 'Pipeline status (success, failed, running, etc.)' }, + detailed_status: { type: 'string', description: 'Detailed pipeline status' }, + ref: { type: 'string', description: 'Ref the pipeline ran on' }, + sha: { type: 'string', description: 'Commit SHA' }, + source: { type: 'string', description: 'Pipeline source (push, web, schedule, etc.)' }, + duration: { type: 'number', description: 'Pipeline duration in seconds' }, + url: { type: 'string', description: 'Pipeline URL' }, + }, + } +} + +export function buildGitLabCommentOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (note)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: actorUserOutputs, + project: projectOutputs, + object_attributes: { + id: { type: 'number', description: 'Comment ID' }, + note: { type: 'string', description: 'Comment body' }, + noteable_type: { + type: 'string', + description: 'What the comment is on (Commit, MergeRequest, Issue, Snippet)', + }, + action: { type: 'string', description: 'Action (create, update)' }, + url: { type: 'string', description: 'Comment URL' }, + }, + } +} + +export function buildGitLabWebhookOutputs(): Record { + return { + object_kind: { type: 'string', description: 'Event kind (push, merge_request, issue, etc.)' }, + event_type: { type: 'string', description: 'GitLab event type from the X-Gitlab-Event header' }, + user: { type: 'json', description: 'Actor that triggered the event (when present)' }, + project: projectOutputs, + object_attributes: { + type: 'json', + description: 'Event-specific attributes (varies by object_kind)', + }, + } +} + +/** + * Returns true when an incoming webhook's object_kind matches the configured trigger. + */ +export function isGitLabEventMatch(triggerId: string, objectKind: string): boolean { + const expected = TRIGGER_OBJECT_KINDS[triggerId] + if (!expected) { + return true + } + return expected === objectKind +} diff --git a/apps/sim/triggers/gitlab/webhook.ts b/apps/sim/triggers/gitlab/webhook.ts new file mode 100644 index 00000000000..a6e5cbdef90 --- /dev/null +++ b/apps/sim/triggers/gitlab/webhook.ts @@ -0,0 +1,33 @@ +import { GitLabIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGitLabExtraFields, + buildGitLabWebhookOutputs, + gitlabSetupInstructions, + gitlabTriggerOptions, +} from '@/triggers/gitlab/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const gitlabWebhookTrigger: TriggerConfig = { + id: 'gitlab_webhook', + name: 'GitLab Event', + provider: 'gitlab', + description: 'Trigger workflow from any GitLab webhook event', + version: '1.0.0', + icon: GitLabIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'gitlab_webhook', + triggerOptions: gitlabTriggerOptions, + setupInstructions: gitlabSetupInstructions('all'), + extraFields: buildGitLabExtraFields('gitlab_webhook'), + }), + outputs: buildGitLabWebhookOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitlab-Event': 'Push Hook', + 'X-Gitlab-Token': '...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_acknowledged.ts b/apps/sim/triggers/pagerduty/incident_acknowledged.ts new file mode 100644 index 00000000000..42c3d8a08fe --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_acknowledged.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentAcknowledgedTrigger: TriggerConfig = { + id: 'pagerduty_incident_acknowledged', + name: 'PagerDuty Incident Acknowledged', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is acknowledged in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_acknowledged', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Acknowledged'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_acknowledged'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_escalated.ts b/apps/sim/triggers/pagerduty/incident_escalated.ts new file mode 100644 index 00000000000..b3e27bc12b4 --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_escalated.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentEscalatedTrigger: TriggerConfig = { + id: 'pagerduty_incident_escalated', + name: 'PagerDuty Incident Escalated', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is escalated in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_escalated', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Escalated'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_escalated'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_reassigned.ts b/apps/sim/triggers/pagerduty/incident_reassigned.ts new file mode 100644 index 00000000000..9725bb34306 --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_reassigned.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentReassignedTrigger: TriggerConfig = { + id: 'pagerduty_incident_reassigned', + name: 'PagerDuty Incident Reassigned', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is reassigned in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_reassigned', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Reassigned'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_reassigned'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_resolved.ts b/apps/sim/triggers/pagerduty/incident_resolved.ts new file mode 100644 index 00000000000..f80d86dd48d --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_resolved.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentResolvedTrigger: TriggerConfig = { + id: 'pagerduty_incident_resolved', + name: 'PagerDuty Incident Resolved', + provider: 'pagerduty', + description: 'Trigger workflow when an incident is resolved in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_resolved', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('Incident Resolved'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_resolved'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/incident_triggered.ts b/apps/sim/triggers/pagerduty/incident_triggered.ts new file mode 100644 index 00000000000..32bcd7db66c --- /dev/null +++ b/apps/sim/triggers/pagerduty/incident_triggered.ts @@ -0,0 +1,33 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyIncidentTriggeredTrigger: TriggerConfig = { + id: 'pagerduty_incident_triggered', + name: 'PagerDuty Incident Triggered', + provider: 'pagerduty', + description: 'Trigger workflow when a new incident is triggered in PagerDuty', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_incident_triggered', + triggerOptions: pagerdutyTriggerOptions, + includeDropdown: true, + setupInstructions: pagerdutySetupInstructions('Incident Triggered'), + extraFields: buildPagerDutyExtraFields('pagerduty_incident_triggered'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/pagerduty/index.ts b/apps/sim/triggers/pagerduty/index.ts new file mode 100644 index 00000000000..0f522e6605a --- /dev/null +++ b/apps/sim/triggers/pagerduty/index.ts @@ -0,0 +1,6 @@ +export { pagerdutyIncidentAcknowledgedTrigger } from './incident_acknowledged' +export { pagerdutyIncidentEscalatedTrigger } from './incident_escalated' +export { pagerdutyIncidentReassignedTrigger } from './incident_reassigned' +export { pagerdutyIncidentResolvedTrigger } from './incident_resolved' +export { pagerdutyIncidentTriggeredTrigger } from './incident_triggered' +export { pagerdutyWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/pagerduty/utils.ts b/apps/sim/triggers/pagerduty/utils.ts new file mode 100644 index 00000000000..18e175d590c --- /dev/null +++ b/apps/sim/triggers/pagerduty/utils.ts @@ -0,0 +1,131 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all PagerDuty triggers + */ +export const pagerdutyTriggerOptions = [ + { label: 'Incident Triggered', id: 'pagerduty_incident_triggered' }, + { label: 'Incident Acknowledged', id: 'pagerduty_incident_acknowledged' }, + { label: 'Incident Resolved', id: 'pagerduty_incident_resolved' }, + { label: 'Incident Escalated', id: 'pagerduty_incident_escalated' }, + { label: 'Incident Reassigned', id: 'pagerduty_incident_reassigned' }, + { label: 'All Incident Events', id: 'pagerduty_webhook' }, +] + +/** + * Maps each PagerDuty trigger to the V3 webhook `event_type` it listens for. + * `pagerduty_webhook` is intentionally absent — it matches every incident event. + */ +const TRIGGER_EVENT_TYPES: Record = { + pagerduty_incident_triggered: 'incident.triggered', + pagerduty_incident_acknowledged: 'incident.acknowledged', + pagerduty_incident_resolved: 'incident.resolved', + pagerduty_incident_escalated: 'incident.escalated', + pagerduty_incident_reassigned: 'incident.reassigned', +} + +/** + * Returns the V3 webhook event types to subscribe to for a given trigger. + * `pagerduty_webhook` subscribes to every supported incident event. + */ +export function getPagerDutyEvents(triggerId: string): string[] { + const specific = TRIGGER_EVENT_TYPES[triggerId] + return specific ? [specific] : Object.values(TRIGGER_EVENT_TYPES) +} + +/** + * Generate setup instructions for a specific PagerDuty incident event. The + * webhook is created automatically on deploy, so the user only supplies an API key. + */ +export function pagerdutySetupInstructions(eventLabel: string): string { + const instructions = [ + 'Create a General Access REST API Key under PagerDuty > Integrations > API Access Keys.', + 'Enter the API key above.', + `Deploy the workflow — Sim creates the account-level webhook subscription in PagerDuty automatically and listens for ${eventLabel}.`, + 'Undeploying the workflow removes the webhook subscription from PagerDuty.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * API key Sim uses to create and delete the PagerDuty webhook subscription. + */ +export function buildPagerDutyExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'PagerDuty General Access REST API key', + description: 'Used to create the webhook subscription. Must be a read/write REST API key.', + password: true, + paramVisibility: 'user-only', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Output schema shared by every PagerDuty incident trigger — V3 webhook + * payloads share the same `event` envelope and `event.data` incident shape. + */ +export function buildPagerDutyIncidentOutputs(): Record { + return { + event_id: { type: 'string', description: 'Unique ID of the webhook event' }, + event_type: { + type: 'string', + description: 'Event type (e.g. incident.triggered, incident.resolved)', + }, + occurred_at: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + agent: { + type: 'json', + description: 'The user or service that caused the event (may be null)', + }, + incident: { + id: { type: 'string', description: 'Incident ID' }, + number: { type: 'number', description: 'Incident number' }, + title: { type: 'string', description: 'Incident title' }, + status: { + type: 'string', + description: 'Incident status (triggered, acknowledged, resolved)', + }, + urgency: { type: 'string', description: 'Incident urgency (high or low)' }, + html_url: { type: 'string', description: 'Web URL of the incident' }, + created_at: { type: 'string', description: 'Incident creation timestamp' }, + priority: { type: 'string', description: 'Priority label (may be null)' }, + service: { + id: { type: 'string', description: 'Service ID' }, + summary: { type: 'string', description: 'Service name' }, + html_url: { type: 'string', description: 'Service web URL' }, + }, + escalation_policy: { + id: { type: 'string', description: 'Escalation policy ID' }, + summary: { type: 'string', description: 'Escalation policy name' }, + html_url: { type: 'string', description: 'Escalation policy web URL' }, + }, + assignees: { + type: 'json', + description: 'Array of assignee references ({ id, summary, html_url })', + }, + }, + } +} + +/** + * Returns true when an incoming V3 webhook event matches the configured trigger. + */ +export function isPagerDutyEventMatch(triggerId: string, eventType: string): boolean { + const expected = TRIGGER_EVENT_TYPES[triggerId] + if (!expected) { + return true + } + return expected === eventType +} diff --git a/apps/sim/triggers/pagerduty/webhook.ts b/apps/sim/triggers/pagerduty/webhook.ts new file mode 100644 index 00000000000..bbd338ab3f4 --- /dev/null +++ b/apps/sim/triggers/pagerduty/webhook.ts @@ -0,0 +1,32 @@ +import { PagerDutyIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildPagerDutyExtraFields, + buildPagerDutyIncidentOutputs, + pagerdutySetupInstructions, + pagerdutyTriggerOptions, +} from '@/triggers/pagerduty/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const pagerdutyWebhookTrigger: TriggerConfig = { + id: 'pagerduty_webhook', + name: 'PagerDuty Incident Event', + provider: 'pagerduty', + description: 'Trigger workflow from any PagerDuty incident event', + version: '1.0.0', + icon: PagerDutyIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'pagerduty_webhook', + triggerOptions: pagerdutyTriggerOptions, + setupInstructions: pagerdutySetupInstructions('all incident events'), + extraFields: buildPagerDutyExtraFields('pagerduty_webhook'), + }), + outputs: buildPagerDutyIncidentOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-PagerDuty-Signature': 'v1=...', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index d388c8ab303..d2926ad7997 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -119,6 +119,14 @@ import { githubWebhookTrigger, githubWorkflowRunTrigger, } from '@/triggers/github' +import { + gitlabCommentTrigger, + gitlabIssueTrigger, + gitlabMergeRequestTrigger, + gitlabPipelineTrigger, + gitlabPushTrigger, + gitlabWebhookTrigger, +} from '@/triggers/gitlab' import { gmailPollingTrigger } from '@/triggers/gmail' import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' import { googleCalendarPollingTrigger } from '@/triggers/google-calendar' @@ -271,6 +279,14 @@ import { notionWebhookTrigger, } from '@/triggers/notion' import { outlookPollingTrigger } from '@/triggers/outlook' +import { + pagerdutyIncidentAcknowledgedTrigger, + pagerdutyIncidentEscalatedTrigger, + pagerdutyIncidentReassignedTrigger, + pagerdutyIncidentResolvedTrigger, + pagerdutyIncidentTriggeredTrigger, + pagerdutyWebhookTrigger, +} from '@/triggers/pagerduty' import { resendEmailBouncedTrigger, resendEmailClickedTrigger, @@ -326,6 +342,13 @@ import { webflowFormSubmissionTrigger, } from '@/triggers/webflow' import { whatsappWebhookTrigger } from '@/triggers/whatsapp' +import { + zendeskTicketCommentAddedTrigger, + zendeskTicketCreatedTrigger, + zendeskTicketPriorityChangedTrigger, + zendeskTicketStatusChangedTrigger, + zendeskWebhookTrigger, +} from '@/triggers/zendesk' import { zoomMeetingEndedTrigger, zoomMeetingStartedTrigger, @@ -443,6 +466,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { github_push: githubPushTrigger, github_release_published: githubReleasePublishedTrigger, github_workflow_run: githubWorkflowRunTrigger, + gitlab_push: gitlabPushTrigger, + gitlab_merge_request: gitlabMergeRequestTrigger, + gitlab_issue: gitlabIssueTrigger, + gitlab_pipeline: gitlabPipelineTrigger, + gitlab_comment: gitlabCommentTrigger, + gitlab_webhook: gitlabWebhookTrigger, fireflies_transcription_complete: firefliesTranscriptionCompleteTrigger, fathom_new_meeting: fathomNewMeetingTrigger, fathom_webhook: fathomWebhookTrigger, @@ -543,6 +572,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { notion_comment_created: notionCommentCreatedTrigger, notion_webhook: notionWebhookTrigger, outlook_poller: outlookPollingTrigger, + pagerduty_incident_triggered: pagerdutyIncidentTriggeredTrigger, + pagerduty_incident_acknowledged: pagerdutyIncidentAcknowledgedTrigger, + pagerduty_incident_resolved: pagerdutyIncidentResolvedTrigger, + pagerduty_incident_escalated: pagerdutyIncidentEscalatedTrigger, + pagerduty_incident_reassigned: pagerdutyIncidentReassignedTrigger, + pagerduty_webhook: pagerdutyWebhookTrigger, resend_email_sent: resendEmailSentTrigger, resend_email_delivered: resendEmailDeliveredTrigger, resend_email_bounced: resendEmailBouncedTrigger, @@ -614,6 +649,11 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { instantly_lead_no_show: instantlyLeadNoShowTrigger, instantly_supersearch_enrichment_completed: instantlySupersearchEnrichmentCompletedTrigger, zoom_meeting_started: zoomMeetingStartedTrigger, + zendesk_ticket_created: zendeskTicketCreatedTrigger, + zendesk_ticket_status_changed: zendeskTicketStatusChangedTrigger, + zendesk_ticket_comment_added: zendeskTicketCommentAddedTrigger, + zendesk_ticket_priority_changed: zendeskTicketPriorityChangedTrigger, + zendesk_webhook: zendeskWebhookTrigger, zoom_meeting_ended: zoomMeetingEndedTrigger, zoom_participant_joined: zoomParticipantJoinedTrigger, zoom_participant_left: zoomParticipantLeftTrigger, diff --git a/apps/sim/triggers/zendesk/index.ts b/apps/sim/triggers/zendesk/index.ts new file mode 100644 index 00000000000..fa1f1df1bf0 --- /dev/null +++ b/apps/sim/triggers/zendesk/index.ts @@ -0,0 +1,5 @@ +export { zendeskTicketCommentAddedTrigger } from './ticket_comment_added' +export { zendeskTicketCreatedTrigger } from './ticket_created' +export { zendeskTicketPriorityChangedTrigger } from './ticket_priority_changed' +export { zendeskTicketStatusChangedTrigger } from './ticket_status_changed' +export { zendeskWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/zendesk/ticket_comment_added.ts b/apps/sim/triggers/zendesk/ticket_comment_added.ts new file mode 100644 index 00000000000..ef8d7e922ef --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_comment_added.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketCommentAddedTrigger: TriggerConfig = { + id: 'zendesk_ticket_comment_added', + name: 'Zendesk Ticket Comment Added', + provider: 'zendesk', + description: 'Trigger workflow when a comment is added to a Zendesk ticket', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_comment_added', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Comment Added'), + extraFields: buildZendeskExtraFields('zendesk_ticket_comment_added'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_created.ts b/apps/sim/triggers/zendesk/ticket_created.ts new file mode 100644 index 00000000000..a156afcf56c --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_created.ts @@ -0,0 +1,34 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketCreatedTrigger: TriggerConfig = { + id: 'zendesk_ticket_created', + name: 'Zendesk Ticket Created', + provider: 'zendesk', + description: 'Trigger workflow when a new ticket is created in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_created', + triggerOptions: zendeskTriggerOptions, + includeDropdown: true, + setupInstructions: zendeskSetupInstructions('Ticket Created'), + extraFields: buildZendeskExtraFields('zendesk_ticket_created'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_priority_changed.ts b/apps/sim/triggers/zendesk/ticket_priority_changed.ts new file mode 100644 index 00000000000..df257b4ea1e --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_priority_changed.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketPriorityChangedTrigger: TriggerConfig = { + id: 'zendesk_ticket_priority_changed', + name: 'Zendesk Ticket Priority Changed', + provider: 'zendesk', + description: 'Trigger workflow when a ticket priority changes in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_priority_changed', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Priority Changed'), + extraFields: buildZendeskExtraFields('zendesk_ticket_priority_changed'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/ticket_status_changed.ts b/apps/sim/triggers/zendesk/ticket_status_changed.ts new file mode 100644 index 00000000000..97d10aa5704 --- /dev/null +++ b/apps/sim/triggers/zendesk/ticket_status_changed.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskTicketStatusChangedTrigger: TriggerConfig = { + id: 'zendesk_ticket_status_changed', + name: 'Zendesk Ticket Status Changed', + provider: 'zendesk', + description: 'Trigger workflow when a ticket status changes in Zendesk', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_ticket_status_changed', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('Ticket Status Changed'), + extraFields: buildZendeskExtraFields('zendesk_ticket_status_changed'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +} diff --git a/apps/sim/triggers/zendesk/utils.ts b/apps/sim/triggers/zendesk/utils.ts new file mode 100644 index 00000000000..1d737240025 --- /dev/null +++ b/apps/sim/triggers/zendesk/utils.ts @@ -0,0 +1,140 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Zendesk triggers + */ +export const zendeskTriggerOptions = [ + { label: 'Ticket Created', id: 'zendesk_ticket_created' }, + { label: 'Ticket Status Changed', id: 'zendesk_ticket_status_changed' }, + { label: 'Ticket Comment Added', id: 'zendesk_ticket_comment_added' }, + { label: 'Ticket Priority Changed', id: 'zendesk_ticket_priority_changed' }, + { label: 'All Ticket Events', id: 'zendesk_webhook' }, +] + +/** + * Maps each Zendesk trigger to the native event-subscription `type` it listens for. + * `zendesk_webhook` is intentionally absent — it matches every ticket event. + */ +const TRIGGER_EVENT_TYPES: Record = { + zendesk_ticket_created: 'zen:event-type:ticket.created', + zendesk_ticket_status_changed: 'zen:event-type:ticket.status_changed', + zendesk_ticket_comment_added: 'zen:event-type:ticket.comment_added', + zendesk_ticket_priority_changed: 'zen:event-type:ticket.priority_changed', +} + +/** + * Returns the native event-subscription types for a given trigger. + * `zendesk_webhook` subscribes to every supported ticket event. + */ +export function getZendeskSubscriptions(triggerId: string): string[] { + const specific = TRIGGER_EVENT_TYPES[triggerId] + return specific ? [specific] : Object.values(TRIGGER_EVENT_TYPES) +} + +/** + * Generate setup instructions for a specific Zendesk ticket event. The webhook + * is created automatically on deploy, so the user only supplies API credentials. + */ +export function zendeskSetupInstructions(eventLabel: string): string { + const instructions = [ + 'Enable token access under Zendesk Admin Center > Apps and integrations > APIs > Zendesk API and create an API token.', + 'Enter your subdomain (from subdomain.zendesk.com), the admin email, and the API token above.', + `Deploy the workflow — Sim creates the event-subscription webhook in Zendesk automatically and listens for ${eventLabel}.`, + 'Undeploying the workflow removes the webhook from Zendesk.', + ] + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Credentials Sim uses to create and delete the Zendesk webhook (admin-scoped). + */ +export function buildZendeskExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'subdomain', + title: 'Subdomain', + type: 'short-input', + placeholder: 'yourcompany (from yourcompany.zendesk.com)', + description: 'Your Zendesk subdomain.', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'email', + title: 'Admin Email', + type: 'short-input', + placeholder: 'admin@yourcompany.com', + description: 'Email of a Zendesk admin used with the API token.', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'apiToken', + title: 'API Token', + type: 'short-input', + placeholder: 'Zendesk API token', + description: 'Used to create the webhook. Requires admin access.', + password: true, + paramVisibility: 'user-only', + required: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Output schema shared by every Zendesk ticket trigger — native + * event-subscription deliveries share the same envelope and `detail` shape. + */ +export function buildZendeskTicketOutputs(): Record { + return { + event_id: { type: 'string', description: 'Unique ID of the webhook event' }, + event_type: { + type: 'string', + description: 'Full event type (e.g. zen:event-type:ticket.created)', + }, + time: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + account_id: { type: 'number', description: 'Zendesk account ID' }, + ticket: { + id: { type: 'string', description: 'Ticket ID' }, + subject: { type: 'string', description: 'Ticket subject' }, + status: { type: 'string', description: 'Ticket status (new, open, pending, solved, etc.)' }, + priority: { type: 'string', description: 'Ticket priority (low, normal, high, urgent)' }, + ticket_type: { + type: 'string', + description: 'Ticket type (question, incident, problem, task)', + }, + description: { type: 'string', description: 'Ticket description' }, + requester_id: { type: 'string', description: 'ID of the requester' }, + assignee_id: { type: 'string', description: 'ID of the assignee' }, + group_id: { type: 'string', description: 'ID of the assigned group' }, + organization_id: { type: 'string', description: 'ID of the organization' }, + tags: { type: 'json', description: 'Array of ticket tags' }, + via_channel: { type: 'string', description: 'Channel the ticket came in through' }, + is_public: { type: 'boolean', description: 'Whether the ticket is public' }, + created_at: { type: 'string', description: 'Ticket creation timestamp' }, + updated_at: { type: 'string', description: 'Ticket last update timestamp' }, + }, + event: { type: 'json', description: 'Event-specific changed data (e.g. status/priority diff)' }, + } +} + +/** + * Returns true when an incoming event-subscription delivery matches the configured trigger. + */ +export function isZendeskEventMatch(triggerId: string, eventType: string): boolean { + const expected = TRIGGER_EVENT_TYPES[triggerId] + if (!expected) { + return true + } + return expected === eventType +} diff --git a/apps/sim/triggers/zendesk/webhook.ts b/apps/sim/triggers/zendesk/webhook.ts new file mode 100644 index 00000000000..23390d6c83b --- /dev/null +++ b/apps/sim/triggers/zendesk/webhook.ts @@ -0,0 +1,33 @@ +import { ZendeskIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildZendeskExtraFields, + buildZendeskTicketOutputs, + zendeskSetupInstructions, + zendeskTriggerOptions, +} from '@/triggers/zendesk/utils' + +export const zendeskWebhookTrigger: TriggerConfig = { + id: 'zendesk_webhook', + name: 'Zendesk Ticket Event', + provider: 'zendesk', + description: 'Trigger workflow from any Zendesk ticket event', + version: '1.0.0', + icon: ZendeskIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zendesk_webhook', + triggerOptions: zendeskTriggerOptions, + setupInstructions: zendeskSetupInstructions('the ticket events you want'), + extraFields: buildZendeskExtraFields('zendesk_webhook'), + }), + outputs: buildZendeskTicketOutputs(), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Zendesk-Webhook-Signature': '...', + 'X-Zendesk-Webhook-Signature-Timestamp': '...', + }, + }, +}