Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/docs/components/ui/icon-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
similarweb: SimilarwebIcon,
sixtyfour: SixtyfourIcon,
slack: SlackIcon,
slack_app: SlackIcon,
slack_v2: SlackIcon,
smartlead: SmartleadIcon,
smtp: SmtpIcon,
snowflake: SnowflakeIcon,
Expand Down
23 changes: 16 additions & 7 deletions apps/docs/content/docs/en/integrations/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Send, update, delete messages, manage views and modals, add or remo
import { BlockInfoCard } from "@/components/ui/block-info-card"

<BlockInfoCard
type="slack"
type="slack_v2"
color="#611f69"
/>

Expand Down Expand Up @@ -1872,18 +1872,27 @@ Set the purpose (description) for a Slack channel (max 250 characters).

A **Trigger** is a block that starts a workflow when an event happens in this service.

### Slack Webhook
### Slack

Trigger workflow from Slack events like mentions, messages, and reactions
Trigger from Slack events (mentions, messages, reactions)

#### Configuration

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `signingSecret` | string | Yes | The signing secret from your Slack app to validate request authenticity. |
| `botToken` | string | No | The bot token from your Slack app. Required for downloading files attached to messages. |
| `includeFiles` | boolean | No | Download and include file attachments from messages. Requires a bot token with files:read scope. |
| `setupWizard` | modal | No | Walk through manifest creation, app install, and pasting credentials. |
| `eventType` | string | Yes | The single Slack event this trigger fires on. Add another trigger block for another event. |
| `customBotCredential` | string | Yes | Choose a custom Slack bot you set up once and reuse across triggers. |
| `manualBotCredential` | string | Yes | Set the custom bot credential ID directly. |
| `source` | string | No | Restrict to direct messages, public channels, or private channels. Leave empty to match any. |
| `channelFilter` | channel-selector | No | Restrict to specific channels. Leave empty to trigger on any channel the bot has been added to. |
| `manualChannelFilter` | string | No | Comma-separated channel IDs to restrict to. Set IDs directly here. |
| `threads` | string | No | Include thread replies, exclude them \(top-level only\), or fire only on thread replies. |
| `emoji` | string | No | Comma-separated emoji names to restrict to. Leave empty to match any emoji. |
| `nameContains` | string | No | Only fire when the created channel name contains this text. |
| `interactionFilter` | string | No | Comma-separated action_ids \(buttons/selects\) or callback_ids \(modals\) to restrict to. Leave empty to fire on any interaction. |
| `filterBotMessages` | boolean | No | Ignore messages sent by other bots. This app's own output is always ignored. |
| `includeOwnMessages` | boolean | No | Also fire on this app's own messages and reactions. Can cause loops — use with care. |
| `includeFiles` | boolean | No | Download and include file attachments from messages. Requires files:read. |

#### Output

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Webhook triggers receive callbacks from the provider and must be able to verify
| Variable | Needed for |
|---|---|
| `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures |
| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Requesting the broader Slack scope set |
| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Enabling the native Sim-app trigger and its broader Slack scope set; set both to the same value |

Your deployment must also be reachable from the provider's servers for webhook triggers to fire — a Sim instance on a private network can use polling triggers but not webhook triggers. Polling triggers additionally require the scheduler; see [Background Jobs](/platform/self-hosting/background-jobs).

Expand Down
17 changes: 17 additions & 0 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,23 @@ describe('OAuth Utils', () => {
expect(result.accessToken).toBe('xoxb-tok')
})

it('returns the bot token for an action-only Slack bot without a signing secret', async () => {
mockSelectChain([
{
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
encryptedServiceAccountKey: 'enc',
},
])
mockDecryptSecret.mockResolvedValueOnce({
decrypted: JSON.stringify({ botToken: 'xoxb-action' }),
})

const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)

expect(result.accessToken).toBe('xoxb-action')
})

it('throws when the Slack bot credential is missing', async () => {
mockSelectChain([])
await expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ describe('Slack custom-bot webhook route', () => {
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
})

it('404s an action-only bot credential without a signing secret', async () => {
mockGetSlackBotCredential.mockResolvedValue({
botToken: 'xoxb-x',
teamId: 'T1',
})

const res = await POST(makeRequest(), context)

expect(res.status).toBe(404)
expect(mockVerifySignature).not.toHaveBeenCalled()
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
})

it('verifies with the credential signing secret and rejects a bad signature', async () => {
mockVerifySignature.mockReturnValue(new Response(null, { status: 401 }))
const res = await POST(makeRequest(), context)
Expand Down Expand Up @@ -133,4 +146,52 @@ describe('Slack custom-bot webhook route', () => {
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
expect(res.status).toBe(200)
})

it('returns 200 when every target permanently lacks its deployed trigger block', async () => {
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'ignored',
response: new Response('Trigger block not found in deployment', { status: 404 }),
reason: 'block-missing',
})

const res = await POST(makeRequest(), context)

expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
expect(res.status).toBe(200)
})

it('returns a retryable failure when another target fails beside a missing block', async () => {
mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1'), webhook('wh2')])
mockDispatchResolvedWebhookTarget
.mockResolvedValueOnce({
outcome: 'ignored',
response: new Response('Trigger block not found in deployment', { status: 404 }),
reason: 'block-missing',
})
.mockResolvedValueOnce({
outcome: 'failed',
response: new Response('Queue failed', { status: 500 }),
reason: 'queue-failed',
})

const res = await POST(makeRequest(), context)

expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2)
expect(res.status).toBe(500)
await expect(res.text()).resolves.toBe('Queue failed')
})

it('returns the dispatch failure when no target is acknowledged', async () => {
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'failed',
response: new Response('Preprocessing failed', { status: 500 }),
reason: 'preprocessing',
})

const res = await POST(makeRequest(), context)

expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
expect(res.status).toBe(500)
await expect(res.text()).resolves.toBe('Preprocessing failed')
})
})
47 changes: 19 additions & 28 deletions apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getSlackBotCredential } from '@/lib/oauth/credential-service'
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch'

const logger = createLogger('SlackCustomBotWebhookAPI')
import { parseWebhookBody } from '@/lib/webhooks/processor'
import { handleSlackChallenge } from '@/lib/webhooks/providers/slack'
import {
dispatchSlackCustomBotCredential,
verifySlackCustomBotCredentialRequest,
} from '@/lib/webhooks/slack-custom-ingress'
import { getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
Expand Down Expand Up @@ -58,31 +58,22 @@ async function handleSlackCustomBotWebhook(
return challenge
}

const botCredential = await getSlackBotCredential(credentialId)
if (!botCredential) {
logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`)
return new NextResponse(null, { status: 404 })
}

const authError = verifySlackRequestSignature(
botCredential.signingSecret,
const authError = await verifySlackCustomBotCredentialRequest({
credentialId,
request,
rawBody,
requestId
)
requestId,
})
if (authError) {
return authError
}

const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack')
if (webhooks.length === 0) {
logger.info(
`[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run`
)
return new NextResponse(null, { status: 200 })
}

await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt })

return new NextResponse(null, { status: 200 })
const dispatchResults = await dispatchSlackCustomBotCredential({
credentialId,
body,
request,
requestId,
receivedAt,
})
return getSlackDispatchResponse(dispatchResults)
}
14 changes: 13 additions & 1 deletion apps/sim/app/api/webhooks/slack/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function webhook(id: string) {

async function run(body: Record<string, unknown>) {
mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) })
await POST(makeRequest())
return POST(makeRequest())
}

const messageBody = {
Expand Down Expand Up @@ -83,6 +83,18 @@ describe('Slack app webhook route', () => {
expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1)
})

it('returns a retryable failure when no target queues', async () => {
mockDispatchResolvedWebhookTarget.mockResolvedValue({
outcome: 'failed',
response: new Response(null, { status: 500 }),
reason: 'queue-failed',
})

const response = await run(messageBody)

expect(response.status).toBe(500)
})

it('routes via Slack Connect authorizations and dedups overlapping webhooks', async () => {
// Two candidate teams (outer + authorization) that resolve to overlapping webhooks.
mockFindWebhooksByRoutingKey.mockImplementation(async (teamId: string) =>
Expand Down
12 changes: 8 additions & 4 deletions apps/sim/app/api/webhooks/slack/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch'
import { dispatchSlackWebhooks, getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch'

const logger = createLogger('SlackAppWebhookAPI')

Expand Down Expand Up @@ -106,7 +106,11 @@ async function handleSlackAppWebhook(request: NextRequest): Promise<NextResponse
return new NextResponse(null, { status: 200 })
}

await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt })

return new NextResponse(null, { status: 200 })
const dispatchResults = await dispatchSlackWebhooks(webhooks, {
body,
request,
requestId,
receivedAt,
})
return getSlackDispatchResponse(dispatchResults)
}
Loading
Loading