mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(webhook): added slack webhook support for slack events (#164)
* feat[webhook]: add Slack integration with signing secret configuration #111 * feat[webhook]: add Slack configuration component for webhook integration Introduced a new SlackConfig component to manage the Slack app signing secret and provide setup instructions. This component includes input fields for the signing secret, a test result display, and detailed setup guidance for integrating with Slack's event subscriptions. * feat[webhook]: implement Slack signature validation helper function * feat[webhook]: refactor POST request to handle slack requests. Also handeling verification challenge from slack for url verification. * feat[webhook]: Add Slack provider configuration support to webhook modal * feat[webhook]: Enhance Slack webhook testing functionality Implement test functionality of slack webhook, #111 * fix(webhook): Improve logica error, removed OR condition on checking the availability of signingSecret #164 * feat(webhook): move Slack signature validation to a dedicated utility file
This commit is contained in:
@@ -224,6 +224,55 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
case 'slack': {
|
||||
const signingSecret = providerConfig.signingSecret
|
||||
|
||||
if (!signingSecret) {
|
||||
logger.warn(`[${requestId}] Slack webhook missing signing secret: ${webhookId}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Webhook has no signing secret configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Slack webhook test successful: ${webhookId}`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
webhook: {
|
||||
id: foundWebhook.id,
|
||||
url: webhookUrl,
|
||||
isActive: foundWebhook.isActive,
|
||||
},
|
||||
message:
|
||||
'Slack webhook configuration is valid. Use this URL in your Slack Event Subscriptions settings.',
|
||||
setup: {
|
||||
url: webhookUrl,
|
||||
events: ['message.channels', 'reaction_added', 'app_mention'],
|
||||
signingSecretConfigured: true,
|
||||
},
|
||||
test: {
|
||||
curlCommand: [
|
||||
`curl -X POST "${webhookUrl}"`,
|
||||
`-H "Content-Type: application/json"`,
|
||||
`-H "X-Slack-Request-Timestamp: $(date +%s)"`,
|
||||
`-H "X-Slack-Signature: v0=$(date +%s)"`,
|
||||
`-d '{"type":"event_callback","event":{"type":"message","channel":"C0123456789","user":"U0123456789","text":"Hello from Slack!","ts":"1234567890.123456"},"team_id":"T0123456789"}'`
|
||||
].join(' \\\n'),
|
||||
samplePayload: {
|
||||
"type": "event_callback",
|
||||
"token": "XXYYZZ",
|
||||
"team_id": "T123ABC",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"user": "U123ABC",
|
||||
"text": "Hello from Slack!",
|
||||
"ts": "1234567890.1234"
|
||||
},
|
||||
"event_id": "Ev123ABC"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
default: {
|
||||
// Generic webhook test
|
||||
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { db } from '@/db'
|
||||
import { environment, userStats, webhook, workflow } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import {validateSlackSignature} from '../../utils'
|
||||
|
||||
const logger = createLogger('WebhookTriggerAPI')
|
||||
|
||||
@@ -113,12 +114,18 @@ export async function POST(
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const executionId = uuidv4()
|
||||
let foundWorkflow: any = null
|
||||
let rawBody: string | null = null
|
||||
|
||||
try {
|
||||
const path = (await params).path
|
||||
|
||||
|
||||
// Clone the request to get both the raw body for Slack signature verification
|
||||
// and the parsed JSON body for processing
|
||||
const requestClone = request.clone()
|
||||
rawBody = await requestClone.text()
|
||||
|
||||
// Parse the request body
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const body = JSON.parse(rawBody || '{}')
|
||||
logger.info(`[${requestId}] Webhook POST request received for path: ${path}`)
|
||||
|
||||
// Generate a unique request ID based on the request content
|
||||
@@ -156,8 +163,68 @@ export async function POST(
|
||||
workflowId: foundWorkflow.id,
|
||||
})
|
||||
|
||||
// For WhatsApp, also check for duplicate messages using their message ID
|
||||
if (foundWebhook.provider === 'whatsapp') {
|
||||
// Provider-specific validation and handling
|
||||
if (foundWebhook.provider === 'slack') {
|
||||
// Validate Slack signature if this is a Slack webhook
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
const signingSecret = providerConfig.signingSecret
|
||||
|
||||
if (signingSecret) {
|
||||
const slackSignature = request.headers.get('x-slack-signature')
|
||||
const slackTimestamp = request.headers.get('x-slack-request-timestamp')
|
||||
|
||||
if (!slackSignature || !slackTimestamp || !rawBody) {
|
||||
logger.warn(`[${requestId}] Missing Slack signature headers`, {
|
||||
hasSignature: !!slackSignature,
|
||||
hasTimestamp: !!slackTimestamp,
|
||||
hasBody: !!rawBody
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid Slack request' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate the Slack signature
|
||||
const isValid = await validateSlackSignature(
|
||||
signingSecret,
|
||||
slackSignature,
|
||||
slackTimestamp,
|
||||
rawBody
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn(`[${requestId}] Invalid Slack signature`)
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Slack signature validated successfully`)
|
||||
|
||||
// Handle Slack URL verification challenge during POST
|
||||
if (body.type === 'url_verification' && body.challenge) {
|
||||
logger.info(`[${requestId}] Responding to Slack URL verification challenge`)
|
||||
return NextResponse.json({ challenge: body.challenge })
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've already processed this message using Redis
|
||||
const messageId = body?.event?.event_id
|
||||
if (messageId && (await hasProcessedMessage(messageId))) {
|
||||
logger.info(`[${requestId}] Duplicate Slack message detected with ID: ${messageId}`)
|
||||
// Return early for duplicate messages to prevent workflow execution
|
||||
return new NextResponse('Duplicate message', { status: 200 })
|
||||
}
|
||||
|
||||
// Store the message ID in Redis to prevent duplicate processing in future requests
|
||||
if (messageId) {
|
||||
await markMessageAsProcessed(messageId)
|
||||
}
|
||||
|
||||
// Mark this request as processed to prevent duplicates
|
||||
await markMessageAsProcessed(requestHash, 60 * 60 * 24)
|
||||
|
||||
// Process the webhook for Slack
|
||||
return await processWebhook(foundWebhook, foundWorkflow, body, request, executionId, requestId)
|
||||
|
||||
} else if (foundWebhook.provider === 'whatsapp') {
|
||||
// Extract WhatsApp specific data
|
||||
const data = body?.entry?.[0]?.changes?.[0]?.value
|
||||
const messages = data?.messages || []
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Validates a Slack webhook request signature using HMAC SHA-256
|
||||
* @param signingSecret - Slack signing secret for validation
|
||||
* @param signature - X-Slack-Signature header value
|
||||
* @param timestamp - X-Slack-Request-Timestamp header value
|
||||
* @param body - Raw request body string
|
||||
* @returns Whether the signature is valid
|
||||
*/
|
||||
|
||||
export async function validateSlackSignature(
|
||||
signingSecret: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
body: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Basic validation first
|
||||
if (!signingSecret || !signature || !timestamp || !body) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the timestamp is too old (> 5 minutes)
|
||||
const currentTime = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute the signature
|
||||
const encoder = new TextEncoder()
|
||||
const baseString = `v0:${timestamp}:${body}`
|
||||
|
||||
// Create the HMAC with the signing secret
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(signingSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
|
||||
const signatureBytes = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
key,
|
||||
encoder.encode(baseString)
|
||||
)
|
||||
|
||||
// Convert the signature to hex
|
||||
const signatureHex = Array.from(new Uint8Array(signatureBytes))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
// Prepare the expected signature format
|
||||
const computedSignature = `v0=${signatureHex}`
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if (computedSignature.length !== signature.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let result = 0
|
||||
for (let i = 0; i < computedSignature.length; i++) {
|
||||
result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i)
|
||||
}
|
||||
|
||||
return result === 0
|
||||
} catch (error) {
|
||||
console.error('Error validating Slack signature:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { CopyableField } from '../ui/copyable'
|
||||
import { TestResultDisplay } from '../ui/test-result'
|
||||
|
||||
interface SlackConfigProps {
|
||||
signingSecret: string
|
||||
setSigningSecret: (secret: string) => void
|
||||
isLoadingToken: boolean
|
||||
testResult: {
|
||||
success: boolean
|
||||
message?: string
|
||||
test?: any
|
||||
} | null
|
||||
copied: string | null
|
||||
copyToClipboard: (text: string, type: string) => void
|
||||
testWebhook: () => Promise<void>
|
||||
|
||||
}
|
||||
|
||||
export function SlackConfig({
|
||||
signingSecret,
|
||||
setSigningSecret,
|
||||
isLoadingToken,
|
||||
testResult,
|
||||
copied,
|
||||
copyToClipboard,
|
||||
}: SlackConfigProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slack-signing-secret">Signing Secret</Label>
|
||||
<CopyableField
|
||||
id="slack-signing-secret"
|
||||
label="Signing Secret"
|
||||
value={signingSecret}
|
||||
onChange={setSigningSecret}
|
||||
placeholder="Enter your Slack app signing secret"
|
||||
description="The signing secret from your Slack app used to validate request authenticity."
|
||||
isLoading={isLoadingToken}
|
||||
copied={copied}
|
||||
copyType="slack-signing-secret"
|
||||
copyToClipboard={copyToClipboard}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The signing secret is provided in your Slack app's Basic Information page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TestResultDisplay
|
||||
testResult={testResult}
|
||||
copied={copied}
|
||||
copyToClipboard={copyToClipboard}
|
||||
showCurlCommand={true}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-medium">Setup Instructions</h4>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>Go to your <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">Slack Apps page</a></li>
|
||||
<li>Create a new app or select an existing one</li>
|
||||
<li>Navigate to "Event Subscriptions" in the left sidebar</li>
|
||||
<li>Enable events and add the Webhook URL above as the Request URL</li>
|
||||
<li>Add the event subscriptions you want to receive (e.g., message.channels)</li>
|
||||
<li>Go to "Basic Information" and copy your Signing Secret</li>
|
||||
<li>Paste the Signing Secret in the field above</li>
|
||||
<li>Save your configuration</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50 dark:bg-emerald-950 p-3 rounded-md mt-3 border border-emerald-200 dark:border-emerald-800">
|
||||
<h5 className="text-sm font-medium text-emerald-800 dark:text-emerald-300">
|
||||
Slack Webhook Features
|
||||
</h5>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<li className="flex items-start">
|
||||
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
Receive events from Slack channels, direct messages, and more
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
Trigger workflows based on messages, reactions, or other Slack events
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
|
||||
<span className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
Securely verify incoming requests with Slack's signing secret
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 dark:bg-purple-950 p-3 rounded-md mt-3 border border-purple-200 dark:border-purple-800">
|
||||
<h5 className="text-sm font-medium text-purple-800 dark:text-purple-300">
|
||||
Example Slack Event
|
||||
</h5>
|
||||
<pre className="mt-2 text-xs bg-black/5 dark:bg-white/5 p-2 rounded overflow-x-auto">
|
||||
{`{
|
||||
"type": "event_callback",
|
||||
"event": {
|
||||
"type": "message",
|
||||
"channel": "C0123456789",
|
||||
"user": "U0123456789",
|
||||
"text": "Hello from Slack!",
|
||||
"ts": "1234567890.123456"
|
||||
},
|
||||
"team_id": "T0123456789",
|
||||
"event_id": "Ev0123456789",
|
||||
"event_time": 1234567890
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@ export function WebhookDialogFooter({
|
||||
onClose,
|
||||
}: WebhookDialogFooterProps) {
|
||||
const showTestButton =
|
||||
webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic') && onTest
|
||||
webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic' || webhookProvider === "slack") && onTest
|
||||
|
||||
return (
|
||||
<DialogFooter className="flex justify-between sticky bottom-0 py-3 bg-background border-t z-10 mt-auto w-full">
|
||||
|
||||
+26
-1
@@ -12,6 +12,7 @@ import { UnsavedChangesDialog } from './ui/confirmation'
|
||||
import { WebhookDialogFooter } from './ui/webhook-footer'
|
||||
import { WebhookDialogHeader } from './ui/webhook-header'
|
||||
import { WebhookUrlField } from './ui/webhook-url'
|
||||
import { SlackConfig } from './providers/slack-config'
|
||||
|
||||
const logger = createLogger('WebhookModal')
|
||||
|
||||
@@ -69,6 +70,7 @@ export function WebhookModal({
|
||||
const [githubContentType, setGithubContentType] = useState('application/json')
|
||||
const [discordWebhookName, setDiscordWebhookName] = useState('')
|
||||
const [discordAvatarUrl, setDiscordAvatarUrl] = useState('')
|
||||
const [slackSigningSecret, setSlackSigningSecret] = useState('')
|
||||
|
||||
// Original values to track changes
|
||||
const [originalValues, setOriginalValues] = useState({
|
||||
@@ -80,6 +82,7 @@ export function WebhookModal({
|
||||
allowedIps: '',
|
||||
discordWebhookName: '',
|
||||
discordAvatarUrl: '',
|
||||
slackSigningSecret: '',
|
||||
})
|
||||
|
||||
// Get the current provider configuration
|
||||
@@ -174,6 +177,10 @@ export function WebhookModal({
|
||||
requireAuth: auth,
|
||||
allowedIps: ips,
|
||||
}))
|
||||
} else if (webhookProvider === 'slack' && 'signingSecret' in config) {
|
||||
const signingSecret = config.signingSecret || ''
|
||||
setSlackSigningSecret(signingSecret)
|
||||
setOriginalValues((prev) => ({ ...prev, slackSigningSecret: signingSecret }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +212,9 @@ export function WebhookModal({
|
||||
(generalToken !== originalValues.generalToken ||
|
||||
secretHeaderName !== originalValues.secretHeaderName ||
|
||||
requireAuth !== originalValues.requireAuth ||
|
||||
allowedIps !== originalValues.allowedIps))
|
||||
allowedIps !== originalValues.allowedIps)) ||
|
||||
(webhookProvider === 'slack' &&
|
||||
slackSigningSecret !== originalValues.slackSigningSecret)
|
||||
|
||||
setHasUnsavedChanges(hasChanges)
|
||||
}, [
|
||||
@@ -219,6 +228,7 @@ export function WebhookModal({
|
||||
requireAuth,
|
||||
allowedIps,
|
||||
originalValues,
|
||||
slackSigningSecret,
|
||||
])
|
||||
|
||||
// Use the provided path or generate a UUID-based path
|
||||
@@ -266,6 +276,8 @@ export function WebhookModal({
|
||||
requireAuth,
|
||||
allowedIps: parsedIps.length > 0 ? parsedIps : undefined,
|
||||
}
|
||||
case 'slack':
|
||||
return { signingSecret: slackSigningSecret }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -296,6 +308,7 @@ export function WebhookModal({
|
||||
allowedIps,
|
||||
discordWebhookName,
|
||||
discordAvatarUrl,
|
||||
slackSigningSecret,
|
||||
})
|
||||
setHasUnsavedChanges(false)
|
||||
}
|
||||
@@ -435,6 +448,18 @@ export function WebhookModal({
|
||||
copyToClipboard={copyToClipboard}
|
||||
/>
|
||||
)
|
||||
case 'slack':
|
||||
return (
|
||||
<SlackConfig
|
||||
signingSecret={slackSigningSecret}
|
||||
setSigningSecret={setSlackSigningSecret}
|
||||
isLoadingToken={isLoadingToken}
|
||||
testResult={testResult}
|
||||
copied={copied}
|
||||
copyToClipboard={copyToClipboard}
|
||||
testWebhook={testWebhook}
|
||||
/>
|
||||
)
|
||||
case 'generic':
|
||||
default:
|
||||
return (
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { CheckCircle2, ExternalLink } from 'lucide-react'
|
||||
import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
|
||||
import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon, SlackIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
@@ -50,6 +50,10 @@ export interface GeneralWebhookConfig {
|
||||
allowedIps?: string[]
|
||||
}
|
||||
|
||||
export interface SlackConfig {
|
||||
signingSecret: string
|
||||
}
|
||||
|
||||
// Union type for all provider configurations
|
||||
export type ProviderConfig =
|
||||
| WhatsAppConfig
|
||||
@@ -57,6 +61,7 @@ export type ProviderConfig =
|
||||
| DiscordConfig
|
||||
| StripeConfig
|
||||
| GeneralWebhookConfig
|
||||
| SlackConfig
|
||||
| Record<string, never>
|
||||
|
||||
// Define available webhook providers
|
||||
@@ -145,6 +150,19 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
|
||||
},
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
icon: (props) => <SlackIcon {...props} />,
|
||||
configFields: {
|
||||
signingSecret: {
|
||||
type: 'string',
|
||||
label: 'Signing Secret',
|
||||
placeholder: 'Enter your Slack app signing secret',
|
||||
description: 'The signing secret from your Slack app to validate request authenticity.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
interface WebhookConfigProps {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
|
||||
{ label: 'WhatsApp', id: 'whatsapp' },
|
||||
{ label: 'GitHub', id: 'github' },
|
||||
{ label: 'Discord', id: 'discord' },
|
||||
{ label: 'Slack', id: 'slack' },
|
||||
// { label: 'Stripe', id: 'stripe' },
|
||||
],
|
||||
value: () => 'generic',
|
||||
|
||||
Reference in New Issue
Block a user