Skip to content

Commit 019061d

Browse files
feat(slack): launch v2 triggers and backfill custom bots
1 parent 7b67a2c commit 019061d

40 files changed

Lines changed: 2667 additions & 226 deletions

File tree

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
507507
similarweb: SimilarwebIcon,
508508
sixtyfour: SixtyfourIcon,
509509
slack: SlackIcon,
510+
slack_app: SlackIcon,
511+
slack_v2: SlackIcon,
510512
smartlead: SmartleadIcon,
511513
smtp: SmtpIcon,
512514
snowflake: SnowflakeIcon,

apps/docs/content/docs/en/integrations/slack.mdx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Send, update, delete messages, manage views and modals, add or remo
66
import { BlockInfoCard } from "@/components/ui/block-info-card"
77

88
<BlockInfoCard
9-
type="slack"
9+
type="slack_v2"
1010
color="#611f69"
1111
/>
1212

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

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

1875-
### Slack Webhook
1875+
### Slack
18761876

1877-
Trigger workflow from Slack events like mentions, messages, and reactions
1877+
Trigger from Slack events (mentions, messages, reactions)
18781878

18791879
#### Configuration
18801880

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

18881897
#### Output
18891898

apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Webhook triggers receive callbacks from the provider and must be able to verify
185185
| Variable | Needed for |
186186
|---|---|
187187
| `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures |
188-
| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Requesting the broader Slack scope set |
188+
| `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 |
189189

190190
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).
191191

apps/sim/app/api/auth/oauth/utils.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,23 @@ describe('OAuth Utils', () => {
445445
expect(result.accessToken).toBe('xoxb-tok')
446446
})
447447

448+
it('returns the bot token for an action-only Slack bot without a signing secret', async () => {
449+
mockSelectChain([
450+
{
451+
type: 'service_account',
452+
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
453+
encryptedServiceAccountKey: 'enc',
454+
},
455+
])
456+
mockDecryptSecret.mockResolvedValueOnce({
457+
decrypted: JSON.stringify({ botToken: 'xoxb-action' }),
458+
})
459+
460+
const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID)
461+
462+
expect(result.accessToken).toBe('xoxb-action')
463+
})
464+
448465
it('throws when the Slack bot credential is missing', async () => {
449466
mockSelectChain([])
450467
await expect(

apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ describe('Slack custom-bot webhook route', () => {
9999
expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled()
100100
})
101101

102+
it('404s an action-only bot credential without a signing secret', async () => {
103+
mockGetSlackBotCredential.mockResolvedValue({
104+
botToken: 'xoxb-x',
105+
teamId: 'T1',
106+
})
107+
108+
const res = await POST(makeRequest(), context)
109+
110+
expect(res.status).toBe(404)
111+
expect(mockVerifySignature).not.toHaveBeenCalled()
112+
expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled()
113+
})
114+
102115
it('verifies with the credential signing secret and rejects a bad signature', async () => {
103116
mockVerifySignature.mockReturnValue(new Response(null, { status: 401 }))
104117
const res = await POST(makeRequest(), context)

apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { createLogger } from '@sim/logger'
21
import { type NextRequest, NextResponse } from 'next/server'
32
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
43
import { generateRequestId } from '@/lib/core/utils/request'
54
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6-
import { getSlackBotCredential } from '@/lib/oauth/credential-service'
7-
import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor'
8-
import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack'
9-
import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch'
10-
11-
const logger = createLogger('SlackCustomBotWebhookAPI')
5+
import { parseWebhookBody } from '@/lib/webhooks/processor'
6+
import { handleSlackChallenge } from '@/lib/webhooks/providers/slack'
7+
import {
8+
dispatchSlackCustomBotCredential,
9+
verifySlackCustomBotCredentialRequest,
10+
} from '@/lib/webhooks/slack-custom-ingress'
1211

1312
export const dynamic = 'force-dynamic'
1413
export const runtime = 'nodejs'
@@ -58,31 +57,17 @@ async function handleSlackCustomBotWebhook(
5857
return challenge
5958
}
6059

61-
const botCredential = await getSlackBotCredential(credentialId)
62-
if (!botCredential) {
63-
logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`)
64-
return new NextResponse(null, { status: 404 })
65-
}
66-
67-
const authError = verifySlackRequestSignature(
68-
botCredential.signingSecret,
60+
const authError = await verifySlackCustomBotCredentialRequest({
61+
credentialId,
6962
request,
7063
rawBody,
71-
requestId
72-
)
64+
requestId,
65+
})
7366
if (authError) {
7467
return authError
7568
}
7669

77-
const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack')
78-
if (webhooks.length === 0) {
79-
logger.info(
80-
`[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run`
81-
)
82-
return new NextResponse(null, { status: 200 })
83-
}
84-
85-
await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt })
70+
await dispatchSlackCustomBotCredential({ credentialId, body, request, requestId, receivedAt })
8671

8772
return new NextResponse(null, { status: 200 })
8873
}

apps/sim/app/api/webhooks/trigger/[path]/route.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface TestWebhook {
7979
path: string
8080
isActive: boolean
8181
providerConfig?: Record<string, unknown>
82+
routingKey?: string | null
8283
workflowId: string
8384
blockId?: string
8485
rateLimitCount?: number
@@ -120,6 +121,9 @@ const {
120121
shouldSkipWebhookEventMock,
121122
admissionRejectedResponseMock,
122123
tryAdmitMock,
124+
getLegacySlackCustomBotCredentialIdMock,
125+
verifySlackCustomBotCredentialRequestMock,
126+
dispatchSlackCustomBotCredentialMock,
123127
} = vi.hoisted(() => ({
124128
generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'),
125129
validateSlackSignatureMock: vi.fn().mockResolvedValue(true),
@@ -179,13 +183,22 @@ const {
179183
shouldSkipWebhookEventMock: vi.fn().mockReturnValue(false),
180184
admissionRejectedResponseMock: vi.fn(),
181185
tryAdmitMock: vi.fn<() => { release: () => void } | null>(() => ({ release: vi.fn() })),
186+
getLegacySlackCustomBotCredentialIdMock: vi.fn(),
187+
verifySlackCustomBotCredentialRequestMock: vi.fn(),
188+
dispatchSlackCustomBotCredentialMock: vi.fn(),
182189
}))
183190

184191
vi.mock('@/lib/core/admission/gate', () => ({
185192
admissionRejectedResponse: admissionRejectedResponseMock,
186193
tryAdmit: tryAdmitMock,
187194
}))
188195

196+
vi.mock('@/lib/webhooks/slack-custom-ingress', () => ({
197+
getLegacySlackCustomBotCredentialId: getLegacySlackCustomBotCredentialIdMock,
198+
verifySlackCustomBotCredentialRequest: verifySlackCustomBotCredentialRequestMock,
199+
dispatchSlackCustomBotCredential: dispatchSlackCustomBotCredentialMock,
200+
}))
201+
189202
vi.mock('@trigger.dev/sdk', () => ({
190203
tasks: {
191204
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
@@ -509,6 +522,14 @@ describe('Webhook Trigger API Route', () => {
509522
workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true)
510523
handleWebhookEventFilterMock.mockResolvedValue(null)
511524
shouldSkipWebhookEventMock.mockReturnValue(false)
525+
getLegacySlackCustomBotCredentialIdMock.mockImplementation((foundWebhook: TestWebhook) => {
526+
const providerConfig = foundWebhook.providerConfig ?? {}
527+
return providerConfig.ingressMode === 'legacy_custom_bot'
528+
? (providerConfig.credentialId as string)
529+
: null
530+
})
531+
verifySlackCustomBotCredentialRequestMock.mockResolvedValue(null)
532+
dispatchSlackCustomBotCredentialMock.mockResolvedValue(1)
512533

513534
// Set up default workflow for tests
514535
testData.workflows.push({
@@ -683,6 +704,64 @@ describe('Webhook Trigger API Route', () => {
683704
})
684705
})
685706

707+
describe('Migrated legacy Slack paths', () => {
708+
it('authenticates by custom-bot credential and replaces direct dispatch with fan-out', async () => {
709+
testData.webhooks.push({
710+
id: 'legacy-slack-webhook',
711+
provider: 'slack',
712+
path: 'legacy-slack-path',
713+
routingKey: 'credential-1',
714+
isActive: true,
715+
providerConfig: {
716+
triggerId: 'slack_webhook',
717+
credentialId: 'credential-1',
718+
ingressMode: 'legacy_custom_bot',
719+
},
720+
workflowId: 'test-workflow-id',
721+
})
722+
723+
const response = await POST(createMockRequest('POST', { type: 'event_callback' }), {
724+
params: Promise.resolve({ path: 'legacy-slack-path' }),
725+
})
726+
727+
expect(response.status).toBe(200)
728+
expect(verifySlackCustomBotCredentialRequestMock).toHaveBeenCalledWith(
729+
expect.objectContaining({ credentialId: 'credential-1' })
730+
)
731+
expect(dispatchSlackCustomBotCredentialMock).toHaveBeenCalledWith(
732+
expect.objectContaining({ credentialId: 'credential-1' })
733+
)
734+
expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled()
735+
})
736+
737+
it('rejects a legacy alias when its credential signature is invalid', async () => {
738+
testData.webhooks.push({
739+
id: 'legacy-slack-webhook',
740+
provider: 'slack',
741+
path: 'legacy-slack-path',
742+
routingKey: 'credential-1',
743+
isActive: true,
744+
providerConfig: {
745+
triggerId: 'slack_webhook',
746+
credentialId: 'credential-1',
747+
ingressMode: 'legacy_custom_bot',
748+
},
749+
workflowId: 'test-workflow-id',
750+
})
751+
verifySlackCustomBotCredentialRequestMock.mockResolvedValueOnce(
752+
new NextResponse('Unauthorized', { status: 401 })
753+
)
754+
755+
const response = await POST(createMockRequest('POST', { type: 'event_callback' }), {
756+
params: Promise.resolve({ path: 'legacy-slack-path' }),
757+
})
758+
759+
expect(response.status).toBe(401)
760+
expect(dispatchSlackCustomBotCredentialMock).not.toHaveBeenCalled()
761+
expect(dispatchResolvedWebhookTargetMock).not.toHaveBeenCalled()
762+
})
763+
})
764+
686765
describe('Reservation-free filtering', () => {
687766
it('skips filtered webhook events before preprocessing reserves a slot', async () => {
688767
testData.webhooks.push({

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ import {
1515
verifyProviderAuth,
1616
} from '@/lib/webhooks/processor'
1717
import { acceptsPathWebhookDelivery } from '@/lib/webhooks/providers'
18+
import {
19+
dispatchSlackCustomBotCredential,
20+
getLegacySlackCustomBotCredentialId,
21+
verifySlackCustomBotCredentialRequest,
22+
} from '@/lib/webhooks/slack-custom-ingress'
1823

1924
const logger = createLogger('WebhookTriggerAPI')
25+
const MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH = 25
2026

2127
export const dynamic = 'force-dynamic'
2228
export const runtime = 'nodejs'
@@ -123,18 +129,69 @@ async function handleWebhookPost(
123129
return new NextResponse('Not Found', { status: 404 })
124130
}
125131

126-
// Process each webhook matched on this path
127-
const responses: NextResponse[] = []
132+
const legacySlackCredentialIds = new Set<string>()
133+
const directWebhooksForPath = webhooksForPath.filter(({ webhook: foundWebhook }) => {
134+
const credentialId = getLegacySlackCustomBotCredentialId(foundWebhook)
135+
if (!credentialId) return true
136+
legacySlackCredentialIds.add(credentialId)
137+
return false
138+
})
139+
if (legacySlackCredentialIds.size > MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH) {
140+
throw new Error(
141+
`Webhook path resolves more than ${MAX_LEGACY_SLACK_CREDENTIALS_PER_PATH} legacy Slack credentials`
142+
)
143+
}
144+
145+
let dispatchedLegacySlackAlias = false
146+
let firstLegacySlackAuthError: NextResponse | null = null
147+
for (const credentialId of legacySlackCredentialIds) {
148+
const authError = await verifySlackCustomBotCredentialRequest({
149+
credentialId,
150+
request,
151+
rawBody,
152+
requestId,
153+
})
154+
if (authError) {
155+
if (authError.status === 404) return authError
156+
firstLegacySlackAuthError ??= authError
157+
continue
158+
}
159+
160+
await dispatchSlackCustomBotCredential({
161+
credentialId,
162+
body,
163+
request,
164+
requestId,
165+
receivedAt,
166+
})
167+
dispatchedLegacySlackAlias = true
168+
}
169+
170+
if (legacySlackCredentialIds.size > 0 && !dispatchedLegacySlackAlias) {
171+
return (
172+
firstLegacySlackAuthError ??
173+
new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 })
174+
)
175+
}
176+
177+
/**
178+
* Process each unmarked webhook matched on this path. Marked Slack rows were
179+
* already included in the routing-key fan-out and must not run twice.
180+
*/
181+
const responses: NextResponse[] = dispatchedLegacySlackAlias
182+
? [new NextResponse(null, { status: 200 })]
183+
: []
128184
const failures: NextResponse[] = []
185+
const dispatchTargetCount = directWebhooksForPath.length + (dispatchedLegacySlackAlias ? 1 : 0)
129186

130-
for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) {
187+
for (const { webhook: foundWebhook, workflow: foundWorkflow } of directWebhooksForPath) {
131188
const provider = foundWebhook.provider
132189
if (!provider) {
133190
const missingProviderResponse = NextResponse.json(
134191
{ error: 'Webhook provider is missing' },
135192
{ status: 500 }
136193
)
137-
if (webhooksForPath.length > 1) {
194+
if (dispatchTargetCount > 1) {
138195
logger.error(
139196
`[${requestId}] Webhook ${foundWebhook.id} has no provider, continuing to next`
140197
)
@@ -151,7 +208,7 @@ async function handleWebhookPost(
151208
requestId
152209
)
153210
if (authError) {
154-
if (webhooksForPath.length > 1) {
211+
if (dispatchTargetCount > 1) {
155212
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
156213
continue
157214
}
@@ -181,7 +238,7 @@ async function handleWebhookPost(
181238
}
182239

183240
if (dispatchResult.outcome === 'failed' || dispatchResult.reason === 'block-missing') {
184-
if (webhooksForPath.length > 1) {
241+
if (dispatchTargetCount > 1) {
185242
logger.warn(
186243
`[${requestId}] Webhook dispatch failed for ${foundWebhook.id}, continuing to next`,
187244
{ reason: dispatchResult.reason, status: dispatchResult.response.status }

0 commit comments

Comments
 (0)