mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-06 04:35:03 -05:00
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
This commit is contained in:
@@ -21,6 +21,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
@@ -74,8 +75,21 @@ async function processTriggerFileOutputs(
|
||||
logger.error(`[${context.requestId}] Error processing ${currentPath}:`, error)
|
||||
processed[key] = val
|
||||
}
|
||||
} else if (
|
||||
outputDef &&
|
||||
typeof outputDef === 'object' &&
|
||||
(outputDef.type === 'object' || outputDef.type === 'json') &&
|
||||
outputDef.properties
|
||||
) {
|
||||
// Explicit object schema with properties - recurse into properties
|
||||
processed[key] = await processTriggerFileOutputs(
|
||||
val,
|
||||
outputDef.properties,
|
||||
context,
|
||||
currentPath
|
||||
)
|
||||
} else if (outputDef && typeof outputDef === 'object' && !outputDef.type) {
|
||||
// Nested object in schema - recurse with the nested schema
|
||||
// Nested object in schema (flat pattern) - recurse with the nested schema
|
||||
processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath)
|
||||
} else {
|
||||
// Not a file output - keep as is
|
||||
@@ -405,11 +419,23 @@ async function executeWebhookJobInternal(
|
||||
const rawSelectedTriggerId = triggerBlock?.subBlocks?.selectedTriggerId?.value
|
||||
const rawTriggerId = triggerBlock?.subBlocks?.triggerId?.value
|
||||
|
||||
const resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find(
|
||||
let resolvedTriggerId = [rawSelectedTriggerId, rawTriggerId].find(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === 'string' && isTriggerValid(candidate)
|
||||
)
|
||||
|
||||
if (!resolvedTriggerId) {
|
||||
const blockConfig = getBlock(triggerBlock.type)
|
||||
if (blockConfig?.category === 'triggers' && isTriggerValid(triggerBlock.type)) {
|
||||
resolvedTriggerId = triggerBlock.type
|
||||
} else if (triggerBlock.triggerMode && blockConfig?.triggers?.enabled) {
|
||||
const available = blockConfig.triggers?.available?.[0]
|
||||
if (available && isTriggerValid(available)) {
|
||||
resolvedTriggerId = available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedTriggerId) {
|
||||
const triggerConfig = getTrigger(resolvedTriggerId)
|
||||
|
||||
|
||||
@@ -527,6 +527,113 @@ export async function validateTwilioSignature(
|
||||
}
|
||||
}
|
||||
|
||||
const SLACK_FILE_HOSTS = new Set(['files.slack.com', 'files-pri.slack.com'])
|
||||
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||
const SLACK_MAX_FILES = 10
|
||||
|
||||
/**
|
||||
* Downloads file attachments from Slack using the bot token.
|
||||
* Returns files in the format expected by WebhookAttachmentProcessor:
|
||||
* { name, data (base64 string), mimeType, size }
|
||||
*
|
||||
* Security:
|
||||
* - Validates each url_private against allowlisted Slack file hosts
|
||||
* - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF
|
||||
* - Enforces per-file size limit and max file count
|
||||
*/
|
||||
async function downloadSlackFiles(
|
||||
rawFiles: any[],
|
||||
botToken: string
|
||||
): Promise<Array<{ name: string; data: string; mimeType: string; size: number }>> {
|
||||
const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES)
|
||||
const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = []
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const urlPrivate = file.url_private as string | undefined
|
||||
if (!urlPrivate) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate the URL points to a known Slack file host
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(urlPrivate)
|
||||
} catch {
|
||||
logger.warn('Slack file has invalid url_private, skipping', { fileId: file.id })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!SLACK_FILE_HOSTS.has(parsedUrl.hostname)) {
|
||||
logger.warn('Slack file url_private points to unexpected host, skipping', {
|
||||
fileId: file.id,
|
||||
hostname: sanitizeUrlForLog(urlPrivate),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip files that exceed the size limit
|
||||
const reportedSize = Number(file.size) || 0
|
||||
if (reportedSize > SLACK_MAX_FILE_SIZE) {
|
||||
logger.warn('Slack file exceeds size limit, skipping', {
|
||||
fileId: file.id,
|
||||
size: reportedSize,
|
||||
limit: SLACK_MAX_FILE_SIZE,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn('Slack file url_private failed DNS validation, skipping', {
|
||||
fileId: file.id,
|
||||
error: urlValidation.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to download Slack file, skipping', {
|
||||
fileId: file.id,
|
||||
status: response.status,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Verify the actual downloaded size doesn't exceed our limit
|
||||
if (buffer.length > SLACK_MAX_FILE_SIZE) {
|
||||
logger.warn('Downloaded Slack file exceeds size limit, skipping', {
|
||||
fileId: file.id,
|
||||
actualSize: buffer.length,
|
||||
limit: SLACK_MAX_FILE_SIZE,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
downloaded.push({
|
||||
name: file.name || 'download',
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
size: buffer.length,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error downloading Slack file, skipping', {
|
||||
fileId: file.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return downloaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Format webhook input based on provider
|
||||
*/
|
||||
@@ -787,43 +894,44 @@ export async function formatWebhookInput(
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'slack') {
|
||||
const event = body?.event
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
const botToken = providerConfig.botToken as string | undefined
|
||||
const includeFiles = Boolean(providerConfig.includeFiles)
|
||||
|
||||
if (event && body?.type === 'event_callback') {
|
||||
return {
|
||||
event: {
|
||||
event_type: event.type || '',
|
||||
channel: event.channel || '',
|
||||
channel_name: '',
|
||||
user: event.user || '',
|
||||
user_name: '',
|
||||
text: event.text || '',
|
||||
timestamp: event.ts || event.event_ts || '',
|
||||
thread_ts: event.thread_ts || '',
|
||||
team_id: body.team_id || event.team || '',
|
||||
event_id: body.event_id || '',
|
||||
},
|
||||
}
|
||||
const rawEvent = body?.event
|
||||
|
||||
if (!rawEvent) {
|
||||
logger.warn('Unknown Slack event type', {
|
||||
type: body?.type,
|
||||
hasEvent: false,
|
||||
bodyKeys: Object.keys(body || {}),
|
||||
})
|
||||
}
|
||||
|
||||
logger.warn('Unknown Slack event type', {
|
||||
type: body?.type,
|
||||
hasEvent: !!body?.event,
|
||||
bodyKeys: Object.keys(body || {}),
|
||||
})
|
||||
const rawFiles: any[] = rawEvent?.files ?? []
|
||||
const hasFiles = rawFiles.length > 0
|
||||
|
||||
let files: any[] = []
|
||||
if (hasFiles && includeFiles && botToken) {
|
||||
files = await downloadSlackFiles(rawFiles, botToken)
|
||||
} else if (hasFiles && includeFiles && !botToken) {
|
||||
logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided')
|
||||
}
|
||||
|
||||
return {
|
||||
event: {
|
||||
event_type: body?.event?.type || body?.type || 'unknown',
|
||||
channel: body?.event?.channel || '',
|
||||
event_type: rawEvent?.type || body?.type || 'unknown',
|
||||
channel: rawEvent?.channel || '',
|
||||
channel_name: '',
|
||||
user: body?.event?.user || '',
|
||||
user: rawEvent?.user || '',
|
||||
user_name: '',
|
||||
text: body?.event?.text || '',
|
||||
timestamp: body?.event?.ts || '',
|
||||
thread_ts: body?.event?.thread_ts || '',
|
||||
team_id: body?.team_id || '',
|
||||
text: rawEvent?.text || '',
|
||||
timestamp: rawEvent?.ts || rawEvent?.event_ts || '',
|
||||
thread_ts: rawEvent?.thread_ts || '',
|
||||
team_id: body?.team_id || rawEvent?.team || '',
|
||||
event_id: body?.event_id || '',
|
||||
hasFiles,
|
||||
files,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,27 @@ export const slackWebhookTrigger: TriggerConfig = {
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'botToken',
|
||||
title: 'Bot Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'xoxb-...',
|
||||
description:
|
||||
'The bot token from your Slack app. Required for downloading files attached to messages.',
|
||||
password: true,
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'includeFiles',
|
||||
title: 'Include File Attachments',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
description:
|
||||
'Download and include file attachments from messages. Requires a bot token with files:read scope.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerSave',
|
||||
title: '',
|
||||
@@ -46,9 +67,10 @@ export const slackWebhookTrigger: TriggerConfig = {
|
||||
'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>',
|
||||
'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>',
|
||||
'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.',
|
||||
'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>',
|
||||
'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>',
|
||||
'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>',
|
||||
'Go to "Install App" in the left sidebar and install the app into your desired Slack workspace and channel.',
|
||||
'Copy the "Bot User OAuth Token" (starts with <code>xoxb-</code>) and paste it in the Bot Token field above to enable file downloads.',
|
||||
'Save changes in both Slack and here.',
|
||||
]
|
||||
.map(
|
||||
@@ -106,6 +128,15 @@ export const slackWebhookTrigger: TriggerConfig = {
|
||||
type: 'string',
|
||||
description: 'Unique event identifier',
|
||||
},
|
||||
hasFiles: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the message has file attachments',
|
||||
},
|
||||
files: {
|
||||
type: 'file[]',
|
||||
description:
|
||||
'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user