Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions apps/docs/components/workflow-preview/format-references.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index} className='text-[var(--brand-secondary)]'>
{part}
</span>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
<span key={index}>{part}</span>
)
})
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.limit(1)

if (existingForBlock.length > 0) {
finalPath = existingForBlock[0].path
finalPath = existingForBlock[0].path ?? ''
logger.info(
`[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}`
)
Expand Down
117 changes: 117 additions & 0 deletions apps/sim/app/api/webhooks/slack/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockParseWebhookBody,
mockFindWebhooksByRoutingKey,
mockCheckWebhookPreprocessing,
mockQueueWebhookExecution,
mockBlockExistsInDeployment,
mockShouldSkip,
} = vi.hoisted(() => ({
mockParseWebhookBody: vi.fn(),
mockFindWebhooksByRoutingKey: vi.fn(),
mockCheckWebhookPreprocessing: vi.fn(),
mockQueueWebhookExecution: vi.fn(),
mockBlockExistsInDeployment: vi.fn(),
mockShouldSkip: vi.fn(),
}))

vi.mock('@/lib/core/admission/gate', () => ({
tryAdmit: () => ({ release: vi.fn() }),
admissionRejectedResponse: () => new Response(null, { status: 503 }),
}))

vi.mock('@/lib/core/config/env', () => ({
env: { SLACK_SIGNING_SECRET: 'test-secret' },
}))

vi.mock('@/lib/webhooks/processor', () => ({
parseWebhookBody: mockParseWebhookBody,
findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey,
checkWebhookPreprocessing: mockCheckWebhookPreprocessing,
queueWebhookExecution: mockQueueWebhookExecution,
}))

vi.mock('@/lib/webhooks/providers/slack', () => ({
handleSlackChallenge: () => null,
verifySlackRequestSignature: () => null,
shouldSkipSlackTriggerEvent: mockShouldSkip,
}))

vi.mock('@/lib/workflows/persistence/utils', () => ({
blockExistsInDeployment: mockBlockExistsInDeployment,
}))

import { POST } from '@/app/api/webhooks/slack/route'

function makeRequest() {
return new Request('https://sim.test/api/webhooks/slack', {
method: 'POST',
headers: { 'x-slack-request-timestamp': '1700000000' },
}) as unknown as import('next/server').NextRequest
}

function webhook(id: string) {
return { webhook: { id, blockId: `blk-${id}`, providerConfig: {} }, workflow: { id: `wf-${id}` } }
}

async function run(body: Record<string, unknown>) {
mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) })
mockCheckWebhookPreprocessing.mockResolvedValue({
actorUserId: 'u1',
executionId: 'e1',
correlation: {},
})
mockBlockExistsInDeployment.mockResolvedValue(true)
await POST(makeRequest())
}

const messageBody = {
team_id: 'T1',
api_app_id: 'A1',
event: { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' },
}

describe('Slack app webhook route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')])
})

it('queues execution when the shared filter does not skip', async () => {
mockShouldSkip.mockReturnValue(false)
await run(messageBody)
expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1)
})

it('does not queue when the shared filter skips the event', async () => {
mockShouldSkip.mockReturnValue(true)
await run(messageBody)
expect(mockQueueWebhookExecution).not.toHaveBeenCalled()
})

it('routes via Slack Connect authorizations and dedups overlapping webhooks', async () => {
mockShouldSkip.mockReturnValue(false)
// Two candidate teams (outer + authorization) that resolve to overlapping webhooks.
mockFindWebhooksByRoutingKey.mockImplementation(async (teamId: string) =>
teamId === 'T1' ? [webhook('wh1')] : [webhook('wh1'), webhook('wh2')]
)
await run({
...messageBody,
authorizations: [{ team_id: 'T2' }],
})
expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledTimes(2)
// wh1 (in both) is queued once, wh2 once — dedup by webhook id.
expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(2)
})

it('returns 200 with no team_id', async () => {
mockShouldSkip.mockReturnValue(false)
await run({ event: { type: 'message' } })
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
expect(mockQueueWebhookExecution).not.toHaveBeenCalled()
})
})
149 changes: 149 additions & 0 deletions apps/sim/app/api/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
checkWebhookPreprocessing,
findWebhooksByRoutingKey,
parseWebhookBody,
queueWebhookExecution,
} from '@/lib/webhooks/processor'
import {
handleSlackChallenge,
shouldSkipSlackTriggerEvent,
verifySlackRequestSignature,
} from '@/lib/webhooks/providers/slack'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'

const logger = createLogger('SlackAppWebhookAPI')

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 60

/**
* Single ingest endpoint for the official Sim Slack app. Every workspace's
* events arrive here and are routed to listening workflows by Slack `team_id`
* (and Slack Connect `authorizations[].team_id`) after HMAC verification with
* the shared app signing secret. This is the request URL configured in the
* app's Event Subscriptions.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const ticket = tryAdmit()
if (!ticket) {
return admissionRejectedResponse()
}

try {
return await handleSlackAppWebhook(request)
} finally {
ticket.release()
}
})

async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse> {
const receivedAt = Date.now()
const requestId = generateRequestId()

const parseResult = await parseWebhookBody(request, requestId)
if (parseResult instanceof NextResponse) {
return parseResult
}
const { body, rawBody } = parseResult

// Slack's endpoint verification handshake — echo the challenge back.
const challenge = handleSlackChallenge(body)
if (challenge) {
return challenge
}

const signingSecret = env.SLACK_SIGNING_SECRET
if (!signingSecret) {
logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`)
return new NextResponse('Slack app not configured', { status: 500 })
}

const authError = verifySlackRequestSignature(signingSecret, request, rawBody, requestId)
if (authError) {
return authError
}

const payload = body as Record<string, unknown>

// Route by the installed workspace(s). For Slack Connect the outer `team_id`
// may be the sender's workspace, so every authorized installation is a
// routing candidate. Team ids are Slack-attested (post-signature), never user
// input.
const teamIds = new Set<string>()
if (typeof payload.team_id === 'string' && payload.team_id.length > 0) {
teamIds.add(payload.team_id)
}
const authorizations = Array.isArray(payload.authorizations) ? payload.authorizations : []
for (const authorization of authorizations) {
const teamId = (authorization as Record<string, unknown>)?.team_id
if (typeof teamId === 'string' && teamId.length > 0) {
teamIds.add(teamId)
}
}
if (teamIds.size === 0) {
logger.warn(`[${requestId}] Slack event missing team_id`)
return new NextResponse(null, { status: 200 })
}

const webhooksById = new Map<string, { webhook: any; workflow: any }>()
for (const teamId of teamIds) {
const found = await findWebhooksByRoutingKey(teamId, requestId)
for (const entry of found) {
webhooksById.set(entry.webhook.id, entry)
}
}
const webhooks = [...webhooksById.values()]
if (webhooks.length === 0) {
return new NextResponse(null, { status: 200 })
}

const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp')
const triggerTimestampMs = slackRequestTimestamp
? Number(slackRequestTimestamp) * 1000
: undefined

for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) {
const providerConfig = (foundWebhook.providerConfig as Record<string, unknown>) || {}

// Apply the shared trigger filter (event, source, threads, emoji, name,
// channels, self-drop, bot). The custom-app path applies the same via
// slackHandler.shouldSkipEvent.
if (shouldSkipSlackTriggerEvent(payload, providerConfig)) {
continue
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (foundWebhook.blockId) {
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
if (!blockExists) {
logger.info(
`[${requestId}] Trigger block ${foundWebhook.blockId} not in deployment for ${foundWorkflow.id}`
)
continue
}
}

const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessResult.error) {
logger.warn(`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}`)
continue
}

await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
actorUserId: preprocessResult.actorUserId,
executionId: preprocessResult.executionId,
correlation: preprocessResult.correlation,
receivedAt,
triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
})
}

return new NextResponse(null, { status: 200 })
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
const authMethod = deps.authMethod as string
const oauthCredential =
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
authMethod === 'bot_token'
? String(deps.botToken ?? '')
: String(deps.credential ?? deps.triggerCredentials ?? '')
return { ...context, oauthCredential }
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
evaluateSubBlockCondition,
hasAdvancedValues,
isCanonicalPair,
isStandaloneAdvancedMode,
resolveCanonicalMode,
shouldUseSubBlockForTriggerModeCanonicalIndex,
} from '@/lib/workflows/subblocks/visibility'
Expand Down Expand Up @@ -185,7 +186,7 @@ export function Editor() {

const hasAdvancedOnlyFields = useMemo(() => {
for (const subBlock of subBlocksForCanonical) {
if (subBlock.mode !== 'advanced') continue
if (!isStandaloneAdvancedMode(subBlock.mode)) continue
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue

if (
Expand Down Expand Up @@ -220,7 +221,8 @@ export function Editor() {

for (const subBlock of subBlocks) {
const isStandaloneAdvanced =
subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id]
isStandaloneAdvancedMode(subBlock.mode) &&
!canonicalIndex.canonicalIdBySubBlockId[subBlock.id]

if (isStandaloneAdvanced) {
advancedOnly.push(subBlock)
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/blocks/blocks/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,7 +1450,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
},
required: true,
},
...getTrigger('slack_webhook').subBlocks,
...getTrigger('slack_oauth').subBlocks,
],
tools: {
access: [
Expand Down Expand Up @@ -2457,7 +2457,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te
// New: Trigger capabilities
triggers: {
enabled: true,
available: ['slack_webhook'],
available: ['slack_oauth'],
},
}

Expand Down
2 changes: 2 additions & 0 deletions apps/sim/blocks/registry-maps.minimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { RssBlock } from '@/blocks/blocks/rss'
import { ScheduleBlock } from '@/blocks/blocks/schedule'
import { SearchBlock } from '@/blocks/blocks/search'
import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event'
import { SlackBlock } from '@/blocks/blocks/slack'
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
import { TableBlock } from '@/blocks/blocks/table'
import { TranslateBlock } from '@/blocks/blocks/translate'
Expand Down Expand Up @@ -72,6 +73,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
schedule: ScheduleBlock,
search: SearchBlock,
sim_workspace_event: SimWorkspaceEventBlock,
slack: SlackBlock,
start_trigger: StartTriggerBlock,
table: TableBlock,
translate: TranslateBlock,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export interface SubBlockConfig {
id: string
title?: string
type: SubBlockType
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is for advanced canonical pair members shown in trigger mode
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle
canonicalParamId?: string
/** Controls parameter visibility in agent/tool-input context */
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export const env = createEnv({
DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret
SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID
SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret
SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger)
REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID
REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret
WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID
Expand Down
Loading
Loading