diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 2d749862756..b356834eacd 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -530,6 +530,66 @@ export async function validateTwilioSignature( const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB const SLACK_MAX_FILES = 15 +/** + * Fetches the original message text for a reaction event using conversations.history. + * Reaction events don't include message text, so we need to fetch it separately. + * Returns empty string if permissions are missing or fetch fails. + */ +async function fetchSlackReactionMessageText( + channel: string, + messageTs: string, + botToken: string +): Promise<{ text: string; user?: string }> { + try { + const url = new URL('https://slack.com/api/conversations.history') + url.searchParams.append('channel', channel) + url.searchParams.append('oldest', messageTs) + url.searchParams.append('limit', '1') + url.searchParams.append('inclusive', 'true') + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${botToken}`, + }, + }) + + const data = (await response.json()) as { + ok: boolean + error?: string + messages?: Array<{ text?: string; user?: string }> + } + + if (!data.ok) { + if (data.error === 'missing_scope') { + logger.debug('Missing scope for fetching reaction message text - channels:history required') + } else if (data.error === 'channel_not_found') { + logger.debug('Channel not found when fetching reaction message text', { channel }) + } else { + logger.warn('Failed to fetch reaction message text', { error: data.error, channel }) + } + return { text: '' } + } + + const messages = data.messages || [] + if (messages.length === 0) { + return { text: '' } + } + + return { + text: messages[0].text || '', + user: messages[0].user, + } + } catch (error) { + logger.warn('Error fetching reaction message text', { + error: error instanceof Error ? error.message : String(error), + channel, + }) + return { text: '' } + } +} + /** * Resolves the full file object from the Slack API when the event payload * only contains a partial file (e.g. missing url_private due to file_access restrictions). @@ -953,6 +1013,51 @@ export async function formatWebhookInput( }) } + const eventType = rawEvent?.type || body?.type || 'unknown' + + // Handle reaction events (reaction_added, reaction_removed) which have a different payload structure + const isReactionEvent = eventType === 'reaction_added' || eventType === 'reaction_removed' + + if (isReactionEvent) { + // Reaction events have item.channel instead of channel, and item.ts for the message timestamp + const itemChannel = rawEvent?.item?.channel || '' + const itemTs = rawEvent?.item?.ts || '' + const reaction = rawEvent?.reaction || '' + const itemUser = rawEvent?.item_user || '' + + // Fetch the original message text if bot token is available + let messageText = '' + let messageAuthor = itemUser + if (botToken && itemChannel && itemTs) { + const messageData = await fetchSlackReactionMessageText(itemChannel, itemTs, botToken) + messageText = messageData.text + if (messageData.user) { + messageAuthor = messageData.user + } + } + + return { + event: { + event_type: eventType, + channel: itemChannel, + channel_name: '', + user: rawEvent?.user || '', + user_name: '', + text: messageText, + reaction, + item_user: messageAuthor, + timestamp: itemTs, + event_ts: rawEvent?.event_ts || '', + thread_ts: '', + team_id: body?.team_id || rawEvent?.team || '', + event_id: body?.event_id || '', + hasFiles: false, + files: [], + }, + } + } + + // Standard message events const rawFiles: any[] = rawEvent?.files ?? [] const hasFiles = rawFiles.length > 0 @@ -965,7 +1070,7 @@ export async function formatWebhookInput( return { event: { - event_type: rawEvent?.type || body?.type || 'unknown', + event_type: eventType, channel: rawEvent?.channel || '', channel_name: '', user: rawEvent?.user || '', diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index 3d22e3be20f..4f891131266 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -67,7 +67,7 @@ export const slackWebhookTrigger: TriggerConfig = { 'Go to Slack Apps page', 'If you don\'t have an app:
', 'Go to "Basic Information", find the "Signing Secret", and paste it in the field above.', - 'Go to "OAuth & Permissions" and add bot token scopes:
', + 'Go to "OAuth & Permissions" and add bot token scopes:
', 'Go to "Event Subscriptions":
', '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 xoxb-) and paste it in the Bot Token field above to enable file downloads.', @@ -90,7 +90,8 @@ export const slackWebhookTrigger: TriggerConfig = { properties: { event_type: { type: 'string', - description: 'Type of Slack event (e.g., app_mention, message)', + description: + 'Type of Slack event (e.g., app_mention, message, reaction_added, reaction_removed)', }, channel: { type: 'string', @@ -102,7 +103,8 @@ export const slackWebhookTrigger: TriggerConfig = { }, user: { type: 'string', - description: 'User ID who triggered the event', + description: + 'User ID who triggered the event (for reactions, the user who added/removed the reaction)', }, user_name: { type: 'string', @@ -110,11 +112,26 @@ export const slackWebhookTrigger: TriggerConfig = { }, text: { type: 'string', - description: 'Message text content', + description: + 'Message text content. For reaction events, fetched via API if bot token has channels:history scope.', + }, + reaction: { + type: 'string', + description: 'Emoji name for reaction events (e.g., "thumbsup", "heart")', + }, + item_user: { + type: 'string', + description: 'User ID of the message author (for reaction events)', }, timestamp: { type: 'string', - description: 'Message timestamp from the triggering event', + description: + 'Message timestamp. For reactions, this is the timestamp of the reacted-to message (item.ts).', + }, + event_ts: { + type: 'string', + description: + 'Event timestamp. For reactions, this is when the reaction was added/removed.', }, thread_ts: { type: 'string',