Skip to content
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 @@ -246,7 +246,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
209 changes: 209 additions & 0 deletions apps/sim/app/api/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
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,
resolveSlackEventChannel,
verifySlackRequestSignature,
} from '@/lib/webhooks/providers/slack'
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
import { SLACK_CHANNEL_SCOPED_EVENTS } from '@/triggers/slack/shared'

const logger = createLogger('SlackAppWebhookAPI')

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

/** Message subtypes that represent real content (vs edits/deletes/system events). */
const CONTENT_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast'])

/**
* Maps an inbound Slack Events API payload to one of the selectable trigger
* event ids (see SLACK_TRIGGER_EVENT_OPTIONS). Returns null for payloads we do
* not surface as trigger operations.
*/
function resolveSlackEventKey(body: Record<string, unknown>): string | null {
const event = body.event as Record<string, unknown> | undefined
if (!event) return null
const type = event.type as string | undefined

if (type === 'app_mention') return 'app_mention'
if (type === 'reaction_added') return 'reaction_added'
if (type === 'reaction_removed') return 'reaction_removed'

if (type === 'message') {
// Only genuine new messages trigger. Edits, deletes, and channel system
// messages (joins, topic/name changes, etc.) arrive as `message` with a
// subtype — ignore all but content subtypes.
const subtype = event.subtype as string | undefined
if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) {
return null
}
switch (event.channel_type as string | undefined) {
case 'im':
return 'message.im'
case 'channel':
return 'message.channels'
case 'group':
return 'message.groups'
default:
return null
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

return null
}

/** True when the message originated from a bot (used to break agent loops). */
function isBotMessage(body: Record<string, unknown>): boolean {
const event = body.event as Record<string, unknown> | undefined
if (!event) return false
return Boolean(event.bot_id) || event.subtype === 'bot_message'
}

function normalizeSelection(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String)
if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim())
return []
}

/**
* 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`
* 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>
const teamId =
typeof payload.team_id === 'string' && payload.team_id.length > 0 ? payload.team_id : null
if (!teamId) {
logger.warn(`[${requestId}] Slack event missing team_id`)
return new NextResponse(null, { status: 200 })
}

const webhooks = await findWebhooksByRoutingKey(teamId, requestId)
if (webhooks.length === 0) {
return new NextResponse(null, { status: 200 })
}

const eventKey = resolveSlackEventKey(payload)
const eventChannel = resolveSlackEventChannel(
payload.event as Record<string, unknown> | undefined
)
const isBot = isBotMessage(payload)
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>) || {}

// Fire only for events that map to a selected Operation. Unmapped events
// (e.g. assistant_thread_*), unselected events, and an empty selection all
// no-op — never bypass the filter.
const selectedEvents = normalizeSelection(providerConfig.events)
if (!eventKey || !selectedEvents.includes(eventKey)) {
continue
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// Channel filter applies only to channel-scoped events, never to DMs.
// Channels come from the picker (channelFilter) or manual IDs
// (manualChannelFilter) — the basic/advanced sides of one canonical field.
// Prefer the picker when set so a stale manual value can't keep matching.
if (eventKey && SLACK_CHANNEL_SCOPED_EVENTS.has(eventKey)) {
const pickerChannels = normalizeSelection(providerConfig.channelFilter)
const selectedChannels =
pickerChannels.length > 0
? pickerChannels
: normalizeSelection(providerConfig.manualChannelFilter)
if (
selectedChannels.length > 0 &&
(!eventChannel || !selectedChannels.includes(eventChannel))
) {
continue
}
}

if (isBot && providerConfig.filterBotMessages !== false) {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom Slack ignores Operations filter

Medium Severity

The unified slack_oauth trigger applies Operations, channel, and bot-message filtering only on /api/webhooks/slack for Sim app type. Custom app deliveries still use the path-based trigger route, which queues every verified Slack event without reading providerConfig.events or the channel filters.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 310061d. Configure here.


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
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
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 @@ -374,6 +374,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
20 changes: 20 additions & 0 deletions apps/sim/lib/credentials/deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,29 @@ export async function clearCredentialRefs(
clearInPausedExecutions(credentialId, workspaceId, needle),
clearInWorkflowCheckpoints(credentialId, workspaceId, needle),
clearInKnowledgeConnectors(credentialId),
deactivateSlackAppWebhooks(credentialId),
])
}

/**
* Deactivates native Slack (`slack_app`) trigger webhooks bound to this
* credential so inbound events stop routing once the account is disconnected.
* The credential id lives in `providerConfig`, not a foreign key, so it is not
* covered by CASCADE.
*/
async function deactivateSlackAppWebhooks(credentialId: string): Promise<void> {
await db
.update(schema.webhook)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(schema.webhook.provider, 'slack_app'),
eq(schema.webhook.isActive, true),
sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}`
)
)
}

async function clearInWorkflowBlocks(
credentialId: string,
workspaceId: string,
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'groups:write',
'chat:write',
'chat:write.public',
// TODO: Add 'assistant:write' once Slack app review is approved
'assistant:write',
'app_mentions:read',
'im:history',
'im:write',
'im:read',
'users:read',
Expand Down
Loading
Loading