-
Notifications
You must be signed in to change notification settings - Fork 3.7k
feat(slack): native OAuth trigger #5323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TheodoreSpeaks
wants to merge
12
commits into
staging
Choose a base branch
from
improve/slack
base: staging
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+18,525
−236
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
a518e43
feat(slack): enable assistant-agent tools via assistant:write scope
TheodoreSpeaks ec1eb62
Add slack trigger
TheodoreSpeaks 5a36f48
fix channel picker in slack trigger
TheodoreSpeaks 7efbfd3
improvement(slack-trigger): reorder app type, gate account to sim mod…
TheodoreSpeaks 4886d1d
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks 71beec4
fix(slack-trigger): drop unmapped events from filter, resolve oauth t…
TheodoreSpeaks a0e723b
fix(slack-trigger): empty operation selection fires nothing; resolve …
TheodoreSpeaks 310061d
fix(slack-trigger): ignore message edit/delete/system subtypes; prefe…
TheodoreSpeaks 65c1415
feat(slack-trigger): single-event model with contextual filters and f…
TheodoreSpeaks 9de9952
fix(slack-trigger): apply event/channel/bot filters on custom-app pat…
TheodoreSpeaks 5ac0932
fix(slack-trigger): don't drop edit/delete events when channel_type i…
TheodoreSpeaks 4692708
Merge remote-tracking branch 'origin/staging' into improve/slack
TheodoreSpeaks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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 }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.