Skip to content

Commit ed5ed97

Browse files
authored
feat(slack): add file attachment support to slack webhook trigger (#3151)
* feat(slack): add file attachment support to slack webhook trigger * additional file handling * lint * ack comment
1 parent 65de273 commit ed5ed97

File tree

3 files changed

+196
-31
lines changed

3 files changed

+196
-31
lines changed

apps/sim/background/webhook-execution.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
2121
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
2222
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
2323
import { getWorkflowById } from '@/lib/workflows/utils'
24+
import { getBlock } from '@/blocks'
2425
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
2526
import type { ExecutionMetadata } from '@/executor/execution/types'
2627
import { hasExecutionResult } from '@/executor/utils/errors'
@@ -74,8 +75,21 @@ async function processTriggerFileOutputs(
7475
logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error)
7576
processed[key] = val
7677
}
78+
} else if (
79+
outputDef &&
80+
typeof outputDef === 'object' &&
81+
(outputDef.type === 'object' || outputDef.type === 'json') &&
82+
outputDef.properties
83+
) {
84+
// Explicit object schema with properties - recurse into properties
85+
processed[key] = await processTriggerFileOutputs(
86+
val,
87+
outputDef.properties,
88+
context,
89+
currentPath
90+
)
7791
} else if (outputDef && typeof outputDef === 'object' && !outputDef.type) {
78-
// Nested object in schema - recurse with the nested schema
92+
// Nested object in schema (flat pattern) - recurse with the nested schema
7993
processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath)
8094
} else {
8195
// Not a file output - keep as is
@@ -405,11 +419,23 @@ async function executeWebhookJobInternal(
405419
const rawSelectedTriggerId = triggerBlock?.subBlocks?.selectedTriggerId?.value
406420
const rawTriggerId = triggerBlock?.subBlocks?.triggerId?.value
407421

408-
const resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find(
422+
let resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find(
409423
(candidate): candidate is string =>
410424
typeof candidate === 'string' && isTriggerValid(candidate)
411425
)
412426

427+
if (!resolvedTriggerId) {
428+
const blockConfig = getBlock(triggerBlock.type)
429+
if (blockConfig?.category === 'triggers' && isTriggerValid(triggerBlock.type)) {
430+
resolvedTriggerId = triggerBlock.type
431+
} else if (triggerBlock.triggerMode && blockConfig?.triggers?.enabled) {
432+
const available = blockConfig.triggers?.available?.[0]
433+
if (available && isTriggerValid(available)) {
434+
resolvedTriggerId = available
435+
}
436+
}
437+
}
438+
413439
if (resolvedTriggerId) {
414440
const triggerConfig = getTrigger(resolvedTriggerId)
415441

apps/sim/lib/webhooks/utils.server.ts

Lines changed: 136 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,113 @@ export async function validateTwilioSignature(
527527
}
528528
}
529529

530+
const SLACK_FILE_HOSTS = new Set(['files.slack.com', 'files-pri.slack.com'])
531+
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
532+
const SLACK_MAX_FILES = 10
533+
534+
/**
535+
* Downloads file attachments from Slack using the bot token.
536+
* Returns files in the format expected by WebhookAttachmentProcessor:
537+
* { name, data (base64 string), mimeType, size }
538+
*
539+
* Security:
540+
* - Validates each url_private against allowlisted Slack file hosts
541+
* - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF
542+
* - Enforces per-file size limit and max file count
543+
*/
544+
async function downloadSlackFiles(
545+
rawFiles: any[],
546+
botToken: string
547+
): Promise<Array<{ name: string; data: string; mimeType: string; size: number }>> {
548+
const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES)
549+
const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = []
550+
551+
for (const file of filesToProcess) {
552+
const urlPrivate = file.url_private as string | undefined
553+
if (!urlPrivate) {
554+
continue
555+
}
556+
557+
// Validate the URL points to a known Slack file host
558+
let parsedUrl: URL
559+
try {
560+
parsedUrl = new URL(urlPrivate)
561+
} catch {
562+
logger.warn('Slack file has invalid url_private, skipping', { fileId: file.id })
563+
continue
564+
}
565+
566+
if (!SLACK_FILE_HOSTS.has(parsedUrl.hostname)) {
567+
logger.warn('Slack file url_private points to unexpected host, skipping', {
568+
fileId: file.id,
569+
hostname: sanitizeUrlForLog(urlPrivate),
570+
})
571+
continue
572+
}
573+
574+
// Skip files that exceed the size limit
575+
const reportedSize = Number(file.size) || 0
576+
if (reportedSize > SLACK_MAX_FILE_SIZE) {
577+
logger.warn('Slack file exceeds size limit, skipping', {
578+
fileId: file.id,
579+
size: reportedSize,
580+
limit: SLACK_MAX_FILE_SIZE,
581+
})
582+
continue
583+
}
584+
585+
try {
586+
const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private')
587+
if (!urlValidation.isValid) {
588+
logger.warn('Slack file url_private failed DNS validation, skipping', {
589+
fileId: file.id,
590+
error: urlValidation.error,
591+
})
592+
continue
593+
}
594+
595+
const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, {
596+
headers: { Authorization: `Bearer ${botToken}` },
597+
})
598+
599+
if (!response.ok) {
600+
logger.warn('Failed to download Slack file, skipping', {
601+
fileId: file.id,
602+
status: response.status,
603+
})
604+
continue
605+
}
606+
607+
const arrayBuffer = await response.arrayBuffer()
608+
const buffer = Buffer.from(arrayBuffer)
609+
610+
// Verify the actual downloaded size doesn't exceed our limit
611+
if (buffer.length > SLACK_MAX_FILE_SIZE) {
612+
logger.warn('Downloaded Slack file exceeds size limit, skipping', {
613+
fileId: file.id,
614+
actualSize: buffer.length,
615+
limit: SLACK_MAX_FILE_SIZE,
616+
})
617+
continue
618+
}
619+
620+
downloaded.push({
621+
name: file.name || 'download',
622+
data: buffer.toString('base64'),
623+
mimeType: file.mimetype || 'application/octet-stream',
624+
size: buffer.length,
625+
})
626+
} catch (error) {
627+
logger.error('Error downloading Slack file, skipping', {
628+
fileId: file.id,
629+
error: error instanceof Error ? error.message : String(error),
630+
})
631+
}
632+
}
633+
634+
return downloaded
635+
}
636+
530637
/**
531638
* Format webhook input based on provider
532639
*/
@@ -787,43 +894,44 @@ export async function formatWebhookInput(
787894
}
788895

789896
if (foundWebhook.provider === 'slack') {
790-
const event = body?.event
897+
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
898+
const botToken = providerConfig.botToken as string | undefined
899+
const includeFiles = Boolean(providerConfig.includeFiles)
791900

792-
if (event && body?.type === 'event_callback') {
793-
return {
794-
event: {
795-
event_type: event.type || '',
796-
channel: event.channel || '',
797-
channel_name: '',
798-
user: event.user || '',
799-
user_name: '',
800-
text: event.text || '',
801-
timestamp: event.ts || event.event_ts || '',
802-
thread_ts: event.thread_ts || '',
803-
team_id: body.team_id || event.team || '',
804-
event_id: body.event_id || '',
805-
},
806-
}
901+
const rawEvent = body?.event
902+
903+
if (!rawEvent) {
904+
logger.warn('Unknown Slack event type', {
905+
type: body?.type,
906+
hasEvent: false,
907+
bodyKeys: Object.keys(body || {}),
908+
})
807909
}
808910

809-
logger.warn('Unknown Slack event type', {
810-
type: body?.type,
811-
hasEvent: !!body?.event,
812-
bodyKeys: Object.keys(body || {}),
813-
})
911+
const rawFiles: any[] = rawEvent?.files ?? []
912+
const hasFiles = rawFiles.length > 0
913+
914+
let files: any[] = []
915+
if (hasFiles && includeFiles && botToken) {
916+
files = await downloadSlackFiles(rawFiles, botToken)
917+
} else if (hasFiles && includeFiles && !botToken) {
918+
logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided')
919+
}
814920

815921
return {
816922
event: {
817-
event_type: body?.event?.type || body?.type || 'unknown',
818-
channel: body?.event?.channel || '',
923+
event_type: rawEvent?.type || body?.type || 'unknown',
924+
channel: rawEvent?.channel || '',
819925
channel_name: '',
820-
user: body?.event?.user || '',
926+
user: rawEvent?.user || '',
821927
user_name: '',
822-
text: body?.event?.text || '',
823-
timestamp: body?.event?.ts || '',
824-
thread_ts: body?.event?.thread_ts || '',
825-
team_id: body?.team_id || '',
928+
text: rawEvent?.text || '',
929+
timestamp: rawEvent?.ts || rawEvent?.event_ts || '',
930+
thread_ts: rawEvent?.thread_ts || '',
931+
team_id: body?.team_id || rawEvent?.team || '',
826932
event_id: body?.event_id || '',
933+
hasFiles,
934+
files,
827935
},
828936
}
829937
}

apps/sim/triggers/slack/webhook.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,27 @@ export const slackWebhookTrigger: TriggerConfig = {
3030
required: true,
3131
mode: 'trigger',
3232
},
33+
{
34+
id: 'botToken',
35+
title: 'Bot Token',
36+
type: 'short-input',
37+
placeholder: 'xoxb-...',
38+
description:
39+
'The bot token from your Slack app. Required for downloading files attached to messages.',
40+
password: true,
41+
required: false,
42+
mode: 'trigger',
43+
},
44+
{
45+
id: 'includeFiles',
46+
title: 'Include File Attachments',
47+
type: 'switch',
48+
defaultValue: false,
49+
description:
50+
'Download and include file attachments from messages. Requires a bot token with files:read scope.',
51+
required: false,
52+
mode: 'trigger',
53+
},
3354
{
3455
id: 'triggerSave',
3556
title: '',
@@ -46,9 +67,10 @@ export const slackWebhookTrigger: TriggerConfig = {
4667
'Go to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" class="text-muted-foreground underline transition-colors hover:text-muted-foreground/80">Slack Apps page</a>',
4768
'If you don\'t have an app:<br><ul class="mt-1 ml-5 list-disc"><li>Create an app from scratch</li><li>Give it a name and select your workspace</li></ul>',
4869
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
49-
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li></ul>',
70+
'Go to "OAuth & Permissions" and add bot token scopes:<br><ul class="mt-1 ml-5 list-disc"><li><code>app_mentions:read</code> - For viewing messages that tag your bot with an @</li><li><code>chat:write</code> - To send messages to channels your bot is a part of</li><li><code>files:read</code> - To access files and images shared in messages</li></ul>',
5071
'Go to "Event Subscriptions":<br><ul class="mt-1 ml-5 list-disc"><li>Enable events</li><li>Under "Subscribe to Bot Events", add <code>app_mention</code> to listen to messages that mention your bot</li><li>Paste the Webhook URL above into the "Request URL" field</li></ul>',
5172
'Go to "Install App" in the left sidebar and install the app into your desired Slack workspace and channel.',
73+
'Copy the "Bot User OAuth Token" (starts with <code>xoxb-</code>) and paste it in the Bot Token field above to enable file downloads.',
5274
'Save changes in both Slack and here.',
5375
]
5476
.map(
@@ -106,6 +128,15 @@ export const slackWebhookTrigger: TriggerConfig = {
106128
type: 'string',
107129
description: 'Unique event identifier',
108130
},
131+
hasFiles: {
132+
type: 'boolean',
133+
description: 'Whether the message has file attachments',
134+
},
135+
files: {
136+
type: 'file[]',
137+
description:
138+
'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)',
139+
},
109140
},
110141
},
111142
},

0 commit comments

Comments
 (0)