From 300aaa5368ad91fcd45ff6c5648914894e222c85 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 15 Dec 2025 17:39:53 -0800 Subject: [PATCH] feat(slack): ability to have DM channels as destination for slack tools (#2388) * feat(slack): tool to allow dms * don't make new tool but separate out destination * add log for message limit * consolidate slack selector code * add scopes correctly * fix zod validation * update message logs * add console logs * fix * remove from tools where feature not needed * add correct condition * fix type * fix cond eval logic --- .../app/api/tools/slack/add-reaction/route.ts | 31 +- .../api/tools/slack/delete-message/route.ts | 29 +- .../api/tools/slack/read-messages/route.ts | 207 +++++++++++++ .../app/api/tools/slack/send-message/route.ts | 243 ++------------- .../api/tools/slack/update-message/route.ts | 2 +- apps/sim/app/api/tools/slack/users/route.ts | 113 +++++++ apps/sim/app/api/tools/slack/utils.ts | 288 ++++++++++++++++++ .../components/oauth-required-modal.tsx | 3 + .../components/sub-block/components/index.ts | 2 +- .../slack-selector-input.tsx} | 59 ++-- .../components/tool-input/tool-input.tsx | 6 +- .../editor/components/sub-block/sub-block.tsx | 5 +- apps/sim/blocks/blocks/slack.ts | 82 ++++- apps/sim/blocks/types.ts | 2 + apps/sim/hooks/selectors/registry.ts | 25 ++ apps/sim/hooks/selectors/resolution.ts | 17 ++ apps/sim/hooks/selectors/types.ts | 1 + apps/sim/lib/auth/auth.ts | 3 + apps/sim/lib/oauth/oauth.ts | 3 + apps/sim/serializer/index.ts | 120 ++++---- apps/sim/tools/slack/message.ts | 11 +- apps/sim/tools/slack/message_reader.ts | 113 ++----- apps/sim/tools/slack/types.ts | 6 +- 23 files changed, 911 insertions(+), 460 deletions(-) create mode 100644 apps/sim/app/api/tools/slack/read-messages/route.ts create mode 100644 apps/sim/app/api/tools/slack/users/route.ts create mode 100644 apps/sim/app/api/tools/slack/utils.ts rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/{channel-selector/channel-selector-input.tsx => slack-selector/slack-selector-input.tsx} (74%) diff --git a/apps/sim/app/api/tools/slack/add-reaction/route.ts b/apps/sim/app/api/tools/slack/add-reaction/route.ts index f6fba4a90..79a48008b 100644 --- a/apps/sim/app/api/tools/slack/add-reaction/route.ts +++ b/apps/sim/app/api/tools/slack/add-reaction/route.ts @@ -1,28 +1,21 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { createLogger } from '@/lib/logs/console/logger' export const dynamic = 'force-dynamic' -const logger = createLogger('SlackAddReactionAPI') - const SlackAddReactionSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel ID is required'), + channel: z.string().min(1, 'Channel is required'), timestamp: z.string().min(1, 'Message timestamp is required'), name: z.string().min(1, 'Emoji name is required'), }) export async function POST(request: NextRequest) { - const requestId = generateRequestId() - try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) if (!authResult.success) { - logger.warn(`[${requestId}] Unauthorized Slack add reaction attempt: ${authResult.error}`) return NextResponse.json( { success: false, @@ -32,22 +25,9 @@ export async function POST(request: NextRequest) { ) } - logger.info( - `[${requestId}] Authenticated Slack add reaction request via ${authResult.authType}`, - { - userId: authResult.userId, - } - ) - const body = await request.json() const validatedData = SlackAddReactionSchema.parse(body) - logger.info(`[${requestId}] Adding Slack reaction`, { - channel: validatedData.channel, - timestamp: validatedData.timestamp, - emoji: validatedData.name, - }) - const slackResponse = await fetch('https://slack.com/api/reactions.add', { method: 'POST', headers: { @@ -64,7 +44,6 @@ export async function POST(request: NextRequest) { const data = await slackResponse.json() if (!data.ok) { - logger.error(`[${requestId}] Slack API error:`, data) return NextResponse.json( { success: false, @@ -74,12 +53,6 @@ export async function POST(request: NextRequest) { ) } - logger.info(`[${requestId}] Reaction added successfully`, { - channel: validatedData.channel, - timestamp: validatedData.timestamp, - reaction: validatedData.name, - }) - return NextResponse.json({ success: true, output: { @@ -93,7 +66,6 @@ export async function POST(request: NextRequest) { }) } catch (error) { if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) return NextResponse.json( { success: false, @@ -104,7 +76,6 @@ export async function POST(request: NextRequest) { ) } - logger.error(`[${requestId}] Error adding Slack reaction:`, error) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/slack/delete-message/route.ts b/apps/sim/app/api/tools/slack/delete-message/route.ts index 02116bec5..25cea4c01 100644 --- a/apps/sim/app/api/tools/slack/delete-message/route.ts +++ b/apps/sim/app/api/tools/slack/delete-message/route.ts @@ -1,27 +1,20 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { createLogger } from '@/lib/logs/console/logger' export const dynamic = 'force-dynamic' -const logger = createLogger('SlackDeleteMessageAPI') - const SlackDeleteMessageSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel ID is required'), + channel: z.string().min(1, 'Channel is required'), timestamp: z.string().min(1, 'Message timestamp is required'), }) export async function POST(request: NextRequest) { - const requestId = generateRequestId() - try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) if (!authResult.success) { - logger.warn(`[${requestId}] Unauthorized Slack delete message attempt: ${authResult.error}`) return NextResponse.json( { success: false, @@ -31,21 +24,9 @@ export async function POST(request: NextRequest) { ) } - logger.info( - `[${requestId}] Authenticated Slack delete message request via ${authResult.authType}`, - { - userId: authResult.userId, - } - ) - const body = await request.json() const validatedData = SlackDeleteMessageSchema.parse(body) - logger.info(`[${requestId}] Deleting Slack message`, { - channel: validatedData.channel, - timestamp: validatedData.timestamp, - }) - const slackResponse = await fetch('https://slack.com/api/chat.delete', { method: 'POST', headers: { @@ -61,7 +42,6 @@ export async function POST(request: NextRequest) { const data = await slackResponse.json() if (!data.ok) { - logger.error(`[${requestId}] Slack API error:`, data) return NextResponse.json( { success: false, @@ -71,11 +51,6 @@ export async function POST(request: NextRequest) { ) } - logger.info(`[${requestId}] Message deleted successfully`, { - channel: data.channel, - timestamp: data.ts, - }) - return NextResponse.json({ success: true, output: { @@ -88,7 +63,6 @@ export async function POST(request: NextRequest) { }) } catch (error) { if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) return NextResponse.json( { success: false, @@ -99,7 +73,6 @@ export async function POST(request: NextRequest) { ) } - logger.error(`[${requestId}] Error deleting Slack message:`, error) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/slack/read-messages/route.ts b/apps/sim/app/api/tools/slack/read-messages/route.ts new file mode 100644 index 000000000..74d9d9742 --- /dev/null +++ b/apps/sim/app/api/tools/slack/read-messages/route.ts @@ -0,0 +1,207 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { createLogger } from '@/lib/logs/console/logger' +import { openDMChannel } from '../utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SlackReadMessagesAPI') + +const SlackReadMessagesSchema = z + .object({ + accessToken: z.string().min(1, 'Access token is required'), + channel: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + limit: z.number().optional().nullable(), + oldest: z.string().optional().nullable(), + latest: z.string().optional().nullable(), + }) + .refine((data) => data.channel || data.userId, { + message: 'Either channel or userId is required', + }) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Slack read messages attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + logger.info( + `[${requestId}] Authenticated Slack read messages request via ${authResult.authType}`, + { + userId: authResult.userId, + } + ) + + const body = await request.json() + const validatedData = SlackReadMessagesSchema.parse(body) + + let channel = validatedData.channel + if (!channel && validatedData.userId) { + logger.info(`[${requestId}] Opening DM channel for user: ${validatedData.userId}`) + channel = await openDMChannel( + validatedData.accessToken, + validatedData.userId, + requestId, + logger + ) + } + + const url = new URL('https://slack.com/api/conversations.history') + url.searchParams.append('channel', channel!) + const limit = validatedData.limit ? Number(validatedData.limit) : 10 + url.searchParams.append('limit', String(Math.min(limit, 15))) + + if (validatedData.oldest) { + url.searchParams.append('oldest', validatedData.oldest) + } + if (validatedData.latest) { + url.searchParams.append('latest', validatedData.latest) + } + + logger.info(`[${requestId}] Reading Slack messages`, { + channel, + limit, + }) + + const slackResponse = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${validatedData.accessToken}`, + }, + }) + + const data = await slackResponse.json() + + if (!data.ok) { + logger.error(`[${requestId}] Slack API error:`, data) + + if (data.error === 'not_in_channel') { + return NextResponse.json( + { + success: false, + error: + 'Bot is not in the channel. Please invite the Sim bot to your Slack channel by typing: /invite @Sim Studio', + }, + { status: 400 } + ) + } + if (data.error === 'channel_not_found') { + return NextResponse.json( + { + success: false, + error: 'Channel not found. Please check the channel ID and try again.', + }, + { status: 400 } + ) + } + if (data.error === 'missing_scope') { + return NextResponse.json( + { + success: false, + error: + 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:history, groups:history, im:history).', + }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + error: data.error || 'Failed to fetch messages', + }, + { status: 400 } + ) + } + + const messages = (data.messages || []).map((message: any) => ({ + type: message.type || 'message', + ts: message.ts, + text: message.text || '', + user: message.user, + bot_id: message.bot_id, + username: message.username, + channel: message.channel, + team: message.team, + thread_ts: message.thread_ts, + parent_user_id: message.parent_user_id, + reply_count: message.reply_count, + reply_users_count: message.reply_users_count, + latest_reply: message.latest_reply, + subscribed: message.subscribed, + last_read: message.last_read, + unread_count: message.unread_count, + subtype: message.subtype, + reactions: message.reactions?.map((reaction: any) => ({ + name: reaction.name, + count: reaction.count, + users: reaction.users || [], + })), + is_starred: message.is_starred, + pinned_to: message.pinned_to, + files: message.files?.map((file: any) => ({ + id: file.id, + name: file.name, + mimetype: file.mimetype, + size: file.size, + url_private: file.url_private, + permalink: file.permalink, + mode: file.mode, + })), + attachments: message.attachments, + blocks: message.blocks, + edited: message.edited + ? { + user: message.edited.user, + ts: message.edited.ts, + } + : undefined, + permalink: message.permalink, + })) + + logger.info(`[${requestId}] Successfully read ${messages.length} messages`) + + return NextResponse.json({ + success: true, + output: { + messages, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { + success: false, + error: 'Invalid request data', + details: error.errors, + }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error reading Slack messages:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 9a82b6e5a..592721d0d 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -3,20 +3,24 @@ import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { createLogger } from '@/lib/logs/console/logger' -import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { sendSlackMessage } from '../utils' export const dynamic = 'force-dynamic' const logger = createLogger('SlackSendMessageAPI') -const SlackSendMessageSchema = z.object({ - accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel is required'), - text: z.string().min(1, 'Message text is required'), - thread_ts: z.string().optional().nullable(), - files: z.array(z.any()).optional().nullable(), -}) +const SlackSendMessageSchema = z + .object({ + accessToken: z.string().min(1, 'Access token is required'), + channel: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + text: z.string().min(1, 'Message text is required'), + thread_ts: z.string().optional().nullable(), + files: z.array(z.any()).optional().nullable(), + }) + .refine((data) => data.channel || data.userId, { + message: 'Either channel or userId is required', + }) export async function POST(request: NextRequest) { const requestId = generateRequestId() @@ -42,222 +46,33 @@ export async function POST(request: NextRequest) { const body = await request.json() const validatedData = SlackSendMessageSchema.parse(body) + const isDM = !!validatedData.userId logger.info(`[${requestId}] Sending Slack message`, { channel: validatedData.channel, + userId: validatedData.userId, + isDM, hasFiles: !!(validatedData.files && validatedData.files.length > 0), fileCount: validatedData.files?.length || 0, }) - if (!validatedData.files || validatedData.files.length === 0) { - logger.info(`[${requestId}] No files, using chat.postMessage`) - - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, - }, - body: JSON.stringify({ - channel: validatedData.channel, - text: validatedData.text, - ...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }), - }), - }) - - const data = await response.json() - - if (!data.ok) { - logger.error(`[${requestId}] Slack API error:`, data.error) - return NextResponse.json( - { - success: false, - error: data.error || 'Failed to send message', - }, - { status: 400 } - ) - } - - logger.info(`[${requestId}] Message sent successfully`) - const messageObj = data.message || { - type: 'message', - ts: data.ts, + const result = await sendSlackMessage( + { + accessToken: validatedData.accessToken, + channel: validatedData.channel ?? undefined, + userId: validatedData.userId ?? undefined, text: validatedData.text, - channel: data.channel, - } - return NextResponse.json({ - success: true, - output: { - message: messageObj, - ts: data.ts, - channel: data.channel, - }, - }) - } - - logger.info(`[${requestId}] Processing ${validatedData.files.length} file(s)`) - - const userFiles = processFilesToUserFiles(validatedData.files, requestId, logger) - - if (userFiles.length === 0) { - logger.warn(`[${requestId}] No valid files to upload`) - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, - }, - body: JSON.stringify({ - channel: validatedData.channel, - text: validatedData.text, - ...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }), - }), - }) - - const data = await response.json() - const messageObj = data.message || { - type: 'message', - ts: data.ts, - text: validatedData.text, - channel: data.channel, - } - return NextResponse.json({ - success: true, - output: { - message: messageObj, - ts: data.ts, - channel: data.channel, - }, - }) - } - - const uploadedFileIds: string[] = [] - - for (const userFile of userFiles) { - logger.info(`[${requestId}] Uploading file: ${userFile.name}`) - - const buffer = await downloadFileFromStorage(userFile, requestId, logger) - - const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Bearer ${validatedData.accessToken}`, - }, - body: new URLSearchParams({ - filename: userFile.name, - length: buffer.length.toString(), - }), - }) - - const urlData = await getUrlResponse.json() - - if (!urlData.ok) { - logger.error(`[${requestId}] Failed to get upload URL:`, urlData.error) - continue - } - - logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`) - - const uploadResponse = await fetch(urlData.upload_url, { - method: 'POST', - body: new Uint8Array(buffer), - }) - - if (!uploadResponse.ok) { - logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`) - continue - } - - logger.info(`[${requestId}] File data uploaded successfully`) - uploadedFileIds.push(urlData.file_id) - } - - if (uploadedFileIds.length === 0) { - logger.warn(`[${requestId}] No files uploaded successfully, sending text-only message`) - const response = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, - }, - body: JSON.stringify({ - channel: validatedData.channel, - text: validatedData.text, - ...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }), - }), - }) - - const data = await response.json() - const messageObj = data.message || { - type: 'message', - ts: data.ts, - text: validatedData.text, - channel: data.channel, - } - return NextResponse.json({ - success: true, - output: { - message: messageObj, - ts: data.ts, - channel: data.channel, - }, - }) - } - - const completeResponse = await fetch('https://slack.com/api/files.completeUploadExternal', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${validatedData.accessToken}`, + threadTs: validatedData.thread_ts ?? undefined, + files: validatedData.files ?? undefined, }, - body: JSON.stringify({ - files: uploadedFileIds.map((id) => ({ id })), - channel_id: validatedData.channel, - initial_comment: validatedData.text, - }), - }) + requestId, + logger + ) - const completeData = await completeResponse.json() - - if (!completeData.ok) { - logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) - return NextResponse.json( - { - success: false, - error: completeData.error || 'Failed to complete file upload', - }, - { status: 400 } - ) + if (!result.success) { + return NextResponse.json({ success: false, error: result.error }, { status: 400 }) } - logger.info(`[${requestId}] Files uploaded and shared successfully`) - - // For file uploads, construct a message object - const fileTs = completeData.files?.[0]?.created?.toString() || (Date.now() / 1000).toString() - const fileMessage = { - type: 'message', - ts: fileTs, - text: validatedData.text, - channel: validatedData.channel, - files: completeData.files?.map((file: any) => ({ - id: file?.id, - name: file?.name, - mimetype: file?.mimetype, - size: file?.size, - url_private: file?.url_private, - permalink: file?.permalink, - })), - } - - return NextResponse.json({ - success: true, - output: { - message: fileMessage, - ts: fileTs, - channel: validatedData.channel, - fileCount: uploadedFileIds.length, - }, - }) + return NextResponse.json({ success: true, output: result.output }) } catch (error) { logger.error(`[${requestId}] Error sending Slack message:`, error) return NextResponse.json( diff --git a/apps/sim/app/api/tools/slack/update-message/route.ts b/apps/sim/app/api/tools/slack/update-message/route.ts index c40b6d34c..d89f9b0a9 100644 --- a/apps/sim/app/api/tools/slack/update-message/route.ts +++ b/apps/sim/app/api/tools/slack/update-message/route.ts @@ -10,7 +10,7 @@ const logger = createLogger('SlackUpdateMessageAPI') const SlackUpdateMessageSchema = z.object({ accessToken: z.string().min(1, 'Access token is required'), - channel: z.string().min(1, 'Channel ID is required'), + channel: z.string().min(1, 'Channel is required'), timestamp: z.string().min(1, 'Message timestamp is required'), text: z.string().min(1, 'Message text is required'), }) diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts new file mode 100644 index 000000000..97d73c88d --- /dev/null +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SlackUsersAPI') + +interface SlackUser { + id: string + name: string + real_name: string + deleted: boolean + is_bot: boolean +} + +export async function POST(request: Request) { + try { + const requestId = generateRequestId() + const body = await request.json() + const { credential, workflowId } = body + + if (!credential) { + logger.error('Missing credential in request') + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } + + let accessToken: string + const isBotToken = credential.startsWith('xoxb-') + + if (isBotToken) { + accessToken = credential + logger.info('Using direct bot token for Slack API') + } else { + const authz = await authorizeCredentialUse(request as any, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + const resolvedToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!resolvedToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + accessToken = resolvedToken + logger.info('Using OAuth token for Slack API') + } + + const data = await fetchSlackUsers(accessToken) + + const users = (data.members || []) + .filter((user: SlackUser) => !user.deleted && !user.is_bot) + .map((user: SlackUser) => ({ + id: user.id, + name: user.name, + real_name: user.real_name || user.name, + })) + + logger.info(`Successfully fetched ${users.length} Slack users`, { + total: data.members?.length || 0, + tokenType: isBotToken ? 'bot_token' : 'oauth', + }) + return NextResponse.json({ users }) + } catch (error) { + logger.error('Error processing Slack users request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Slack users', details: (error as Error).message }, + { status: 500 } + ) + } +} + +async function fetchSlackUsers(accessToken: string) { + const url = new URL('https://slack.com/api/users.list') + url.searchParams.append('limit', '200') + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Slack API error: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.ok) { + throw new Error(data.error || 'Failed to fetch users') + } + + return data +} diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts new file mode 100644 index 000000000..b52d73420 --- /dev/null +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -0,0 +1,288 @@ +import type { Logger } from '@/lib/logs/console/logger' +import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' + +/** + * Sends a message to a Slack channel using chat.postMessage + */ +export async function postSlackMessage( + accessToken: string, + channel: string, + text: string, + threadTs?: string | null +): Promise<{ ok: boolean; ts?: string; channel?: string; message?: any; error?: string }> { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + channel, + text, + ...(threadTs && { thread_ts: threadTs }), + }), + }) + + return response.json() +} + +/** + * Creates a default message object when the API doesn't return one + */ +export function createDefaultMessageObject( + ts: string, + text: string, + channel: string +): Record { + return { + type: 'message', + ts, + text, + channel, + } +} + +/** + * Formats the success response for a sent message + */ +export function formatMessageSuccessResponse( + data: any, + text: string +): { + message: any + ts: string + channel: string +} { + const messageObj = data.message || createDefaultMessageObject(data.ts, text, data.channel) + return { + message: messageObj, + ts: data.ts, + channel: data.channel, + } +} + +/** + * Uploads files to Slack and returns the uploaded file IDs + */ +export async function uploadFilesToSlack( + files: any[], + accessToken: string, + requestId: string, + logger: Logger +): Promise { + const userFiles = processFilesToUserFiles(files, requestId, logger) + const uploadedFileIds: string[] = [] + + for (const userFile of userFiles) { + logger.info(`[${requestId}] Uploading file: ${userFile.name}`) + + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + + const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${accessToken}`, + }, + body: new URLSearchParams({ + filename: userFile.name, + length: buffer.length.toString(), + }), + }) + + const urlData = await getUrlResponse.json() + + if (!urlData.ok) { + logger.error(`[${requestId}] Failed to get upload URL:`, urlData.error) + continue + } + + logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`) + + const uploadResponse = await fetch(urlData.upload_url, { + method: 'POST', + body: new Uint8Array(buffer), + }) + + if (!uploadResponse.ok) { + logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`) + continue + } + + logger.info(`[${requestId}] File data uploaded successfully`) + uploadedFileIds.push(urlData.file_id) + } + + return uploadedFileIds +} + +/** + * Completes the file upload process by associating files with a channel + */ +export async function completeSlackFileUpload( + uploadedFileIds: string[], + channel: string, + text: string, + accessToken: string +): Promise<{ ok: boolean; files?: any[]; error?: string }> { + const response = await fetch('https://slack.com/api/files.completeUploadExternal', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + files: uploadedFileIds.map((id) => ({ id })), + channel_id: channel, + initial_comment: text, + }), + }) + + return response.json() +} + +/** + * Creates a message object for file uploads + */ +export function createFileMessageObject( + text: string, + channel: string, + files: any[] +): Record { + const fileTs = files?.[0]?.created?.toString() || (Date.now() / 1000).toString() + return { + type: 'message', + ts: fileTs, + text, + channel, + files: files?.map((file: any) => ({ + id: file?.id, + name: file?.name, + mimetype: file?.mimetype, + size: file?.size, + url_private: file?.url_private, + permalink: file?.permalink, + })), + } +} + +/** + * Opens a DM channel with a user and returns the channel ID + */ +export async function openDMChannel( + accessToken: string, + userId: string, + requestId: string, + logger: Logger +): Promise { + const response = await fetch('https://slack.com/api/conversations.open', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + users: userId, + }), + }) + + const data = await response.json() + + if (!data.ok) { + logger.error(`[${requestId}] Failed to open DM channel:`, data.error) + throw new Error(data.error || 'Failed to open DM channel with user') + } + + logger.info(`[${requestId}] Opened DM channel: ${data.channel.id}`) + return data.channel.id +} + +export interface SlackMessageParams { + accessToken: string + channel?: string + userId?: string + text: string + threadTs?: string | null + files?: any[] | null +} + +/** + * Sends a Slack message with optional file attachments + * Supports both channel messages and direct messages via userId + */ +export async function sendSlackMessage( + params: SlackMessageParams, + requestId: string, + logger: Logger +): Promise<{ + success: boolean + output?: { message: any; ts: string; channel: string; fileCount?: number } + error?: string +}> { + const { accessToken, text, threadTs, files } = params + let { channel } = params + + if (!channel && params.userId) { + logger.info(`[${requestId}] Opening DM channel for user: ${params.userId}`) + channel = await openDMChannel(accessToken, params.userId, requestId, logger) + } + + if (!channel) { + return { success: false, error: 'Either channel or userId is required' } + } + + // No files - simple message + if (!files || files.length === 0) { + logger.info(`[${requestId}] No files, using chat.postMessage`) + + const data = await postSlackMessage(accessToken, channel, text, threadTs) + + if (!data.ok) { + logger.error(`[${requestId}] Slack API error:`, data.error) + return { success: false, error: data.error || 'Failed to send message' } + } + + logger.info(`[${requestId}] Message sent successfully`) + return { success: true, output: formatMessageSuccessResponse(data, text) } + } + + // Process files + logger.info(`[${requestId}] Processing ${files.length} file(s)`) + const uploadedFileIds = await uploadFilesToSlack(files, accessToken, requestId, logger) + + // No valid files uploaded - send text-only + if (uploadedFileIds.length === 0) { + logger.warn(`[${requestId}] No valid files to upload, sending text-only message`) + + const data = await postSlackMessage(accessToken, channel, text, threadTs) + + if (!data.ok) { + return { success: false, error: data.error || 'Failed to send message' } + } + + return { success: true, output: formatMessageSuccessResponse(data, text) } + } + + // Complete file upload + const completeData = await completeSlackFileUpload(uploadedFileIds, channel, text, accessToken) + + if (!completeData.ok) { + logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) + return { success: false, error: completeData.error || 'Failed to complete file upload' } + } + + logger.info(`[${requestId}] Files uploaded and shared successfully`) + + const fileMessage = createFileMessageObject(text, channel, completeData.files || []) + + return { + success: true, + output: { + message: fileMessage, + ts: fileMessage.ts, + channel, + fileCount: uploadedFileIds.length, + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 3a3078c95..818defe02 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -179,6 +179,9 @@ const SCOPE_DESCRIPTIONS: Record = { 'groups:history': 'Read private messages', 'chat:write': 'Send messages', 'chat:write.public': 'Post to public channels', + 'im:write': 'Send direct messages', + 'im:history': 'Read direct message history', + 'im:read': 'View direct message channels', 'users:read': 'View workspace users', 'files:write': 'Upload files', 'files:read': 'Download and read files', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 28f9a609a..dc5ba115e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -1,4 +1,3 @@ -export { ChannelSelectorInput } from './channel-selector/channel-selector-input' export { CheckboxList } from './checkbox-list/checkbox-list' export { Code } from './code/code' export { ComboBox } from './combobox/combobox' @@ -24,6 +23,7 @@ export { ProjectSelectorInput } from './project-selector/project-selector-input' export { ResponseFormat } from './response/response-format' export { ScheduleSave } from './schedule-save/schedule-save' export { ShortInput } from './short-input/short-input' +export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' export { InputFormat } from './starter/input-format' export { SubBlockInputController } from './sub-block-input-controller' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx similarity index 74% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx index 245dbd51c..9267ff174 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/channel-selector/channel-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/slack-selector/slack-selector-input.tsx @@ -9,30 +9,51 @@ import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useForeignCredential } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-foreign-credential' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' +import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' -interface ChannelSelectorInputProps { +type SlackSelectorType = 'channel-selector' | 'user-selector' + +const SELECTOR_CONFIG: Record< + SlackSelectorType, + { selectorKey: SelectorKey; placeholder: string; label: string } +> = { + 'channel-selector': { + selectorKey: 'slack.channels', + placeholder: 'Select Slack channel', + label: 'Channel', + }, + 'user-selector': { + selectorKey: 'slack.users', + placeholder: 'Select Slack user', + label: 'User', + }, +} + +interface SlackSelectorInputProps { blockId: string subBlock: SubBlockConfig disabled?: boolean - onChannelSelect?: (channelId: string) => void + onSelect?: (value: string) => void isPreview?: boolean previewValue?: any | null previewContextValues?: Record } -export function ChannelSelectorInput({ +export function SlackSelectorInput({ blockId, subBlock, disabled = false, - onChannelSelect, + onSelect, isPreview = false, previewValue, previewContextValues, -}: ChannelSelectorInputProps) { +}: SlackSelectorInputProps) { + const selectorType = subBlock.type as SlackSelectorType + const config = SELECTOR_CONFIG[selectorType] + const params = useParams() const workflowIdFromUrl = (params?.workflowId as string) || '' - const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + const [storeValue] = useSubBlockValue(blockId, subBlock.id) const [authMethod] = useSubBlockValue(blockId, 'authMethod') const [botToken] = useSubBlockValue(blockId, 'botToken') const [connectedCredential] = useSubBlockValue(blockId, 'credential') @@ -40,37 +61,32 @@ export function ChannelSelectorInput({ const effectiveAuthMethod = previewContextValues?.authMethod ?? authMethod const effectiveBotToken = previewContextValues?.botToken ?? botToken const effectiveCredential = previewContextValues?.credential ?? connectedCredential - const [_channelInfo, setChannelInfo] = useState(null) + const [_selectedValue, setSelectedValue] = useState(null) - // Use serviceId to identify the service and derive providerId for credential lookup const serviceId = subBlock.serviceId || '' const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) const isSlack = serviceId === 'slack' - // Central dependsOn gating const { finalDisabled, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview, previewContextValues, }) - // Choose credential strictly based on auth method - use effective values const credential: string = (effectiveAuthMethod as string) === 'bot_token' ? (effectiveBotToken as string) || '' : (effectiveCredential as string) || '' - // Determine if connected OAuth credential is foreign (not applicable for bot tokens) const { isForeignCredential } = useForeignCredential( effectiveProviderId, (effectiveAuthMethod as string) === 'bot_token' ? '' : (effectiveCredential as string) || '' ) - // Get the current value from the store or prop value if in preview mode (same pattern as file-selector) useEffect(() => { const val = isPreview && previewValue !== undefined ? previewValue : storeValue if (typeof val === 'string') { - setChannelInfo(val) + setSelectedValue(val) } }, [isPreview, previewValue, storeValue]) @@ -91,11 +107,14 @@ export function ChannelSelectorInput({
- Channel selector not supported for service: {serviceId || 'unknown'} + {config.label} selector not supported for service: {serviceId || 'unknown'}
-

This channel selector is not yet implemented for {serviceId || 'unknown'}

+

+ This {config.label.toLowerCase()} selector is not yet implemented for{' '} + {serviceId || 'unknown'} +

) @@ -108,16 +127,16 @@ export function ChannelSelectorInput({ { - setChannelInfo(value) + setSelectedValue(value) if (!isPreview) { - onChannelSelect?.(value) + onSelect?.(value) } }} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index eaeecce09..d3f9534d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -24,7 +24,6 @@ import { type OAuthService, } from '@/lib/oauth/oauth' import { - ChannelSelectorInput, CheckboxList, Code, ComboBox, @@ -33,6 +32,7 @@ import { LongInput, ProjectSelectorInput, ShortInput, + SlackSelectorInput, SliderInput, Table, TimeInput, @@ -520,7 +520,7 @@ function ChannelSelectorSyncWrapper({ }) { return ( - diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 395c4c61d..0fb5dc6ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/core/utils/cn' import type { FieldDiffStatus } from '@/lib/workflows/diff/types' import { - ChannelSelectorInput, CheckboxList, Code, ComboBox, @@ -32,6 +31,7 @@ import { ResponseFormat, ScheduleSave, ShortInput, + SlackSelectorInput, SliderInput, Switch, Table, @@ -732,8 +732,9 @@ function SubBlockComponent({ ) case 'channel-selector': + case 'user-selector': return ( - = { value: () => 'oauth', required: true, }, + { + id: 'destinationType', + title: 'Destination', + type: 'dropdown', + options: [ + { label: 'Channel', id: 'channel' }, + { label: 'Direct Message', id: 'dm' }, + ], + value: () => 'channel', + condition: { + field: 'operation', + value: ['send', 'read'], + }, + }, { id: 'credential', title: 'Slack Account', @@ -60,6 +74,9 @@ export const SlackBlock: BlockConfig = { 'groups:history', 'chat:write', 'chat:write.public', + 'im:write', + 'im:history', + 'im:read', 'users:read', 'files:write', 'files:read', @@ -98,9 +115,13 @@ export const SlackBlock: BlockConfig = { field: 'operation', value: ['list_channels', 'list_users', 'get_user'], not: true, + and: { + field: 'destinationType', + value: 'dm', + not: true, + }, }, }, - // Manual channel ID input (advanced mode) { id: 'manualChannel', title: 'Channel ID', @@ -112,6 +133,37 @@ export const SlackBlock: BlockConfig = { field: 'operation', value: ['list_channels', 'list_users', 'get_user'], not: true, + and: { + field: 'destinationType', + value: 'dm', + not: true, + }, + }, + }, + { + id: 'dmUserId', + title: 'User', + type: 'user-selector', + canonicalParamId: 'dmUserId', + serviceId: 'slack', + placeholder: 'Select Slack user', + mode: 'basic', + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + condition: { + field: 'destinationType', + value: 'dm', + }, + }, + { + id: 'manualDmUserId', + title: 'User ID', + type: 'short-input', + canonicalParamId: 'dmUserId', + placeholder: 'Enter Slack user ID (e.g., U1234567890)', + mode: 'advanced', + condition: { + field: 'destinationType', + value: 'dm', }, }, { @@ -137,7 +189,6 @@ export const SlackBlock: BlockConfig = { }, required: false, }, - // File upload (basic mode) { id: 'attachmentFiles', title: 'Attachments', @@ -149,7 +200,6 @@ export const SlackBlock: BlockConfig = { multiple: true, required: false, }, - // Variable reference (advanced mode) { id: 'files', title: 'File Attachments', @@ -416,8 +466,11 @@ export const SlackBlock: BlockConfig = { authMethod, botToken, operation, + destinationType, channel, manualChannel, + dmUserId, + manualDmUserId, text, title, content, @@ -440,21 +493,26 @@ export const SlackBlock: BlockConfig = { ...rest } = params - // Handle both selector and manual channel input + const isDM = destinationType === 'dm' const effectiveChannel = (channel || manualChannel || '').trim() + const effectiveUserId = (dmUserId || manualDmUserId || '').trim() - // Operations that don't require a channel const noChannelOperations = ['list_channels', 'list_users', 'get_user'] + const dmSupportedOperations = ['send', 'read'] - // Channel is required for most operations - if (!effectiveChannel && !noChannelOperations.includes(operation)) { + if (isDM && dmSupportedOperations.includes(operation)) { + if (!effectiveUserId) { + throw new Error('User is required for DM operations.') + } + } else if (!effectiveChannel && !noChannelOperations.includes(operation)) { throw new Error('Channel is required.') } const baseParams: Record = {} - // Only add channel if we have one (not needed for list_channels) - if (effectiveChannel) { + if (isDM && dmSupportedOperations.includes(operation)) { + baseParams.userId = effectiveUserId + } else if (effectiveChannel) { baseParams.channel = effectiveChannel } @@ -472,18 +530,15 @@ export const SlackBlock: BlockConfig = { baseParams.credential = credential } - // Handle operation-specific params switch (operation) { case 'send': { if (!text || text.trim() === '') { throw new Error('Message text is required for send operation') } baseParams.text = text - // Add thread_ts if provided if (threadTs) { baseParams.thread_ts = threadTs } - // Add files if provided const fileParam = attachmentFiles || files if (fileParam) { baseParams.files = fileParam @@ -592,10 +647,13 @@ export const SlackBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, authMethod: { type: 'string', description: 'Authentication method' }, + destinationType: { type: 'string', description: 'Destination type (channel or dm)' }, credential: { type: 'string', description: 'Slack access token' }, botToken: { type: 'string', description: 'Bot token' }, channel: { type: 'string', description: 'Channel identifier' }, manualChannel: { type: 'string', description: 'Manual channel identifier' }, + dmUserId: { type: 'string', description: 'User ID for DM recipient (selector)' }, + manualDmUserId: { type: 'string', description: 'User ID for DM recipient (manual input)' }, text: { type: 'string', description: 'Message text' }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, files: { type: 'array', description: 'Files to attach (UserFile array)' }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 7cc0116c9..64c1a89a2 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -59,6 +59,7 @@ export type SubBlockType = | 'file-selector' // File selector for Google Drive, etc. | 'project-selector' // Project selector for Jira, Discord, etc. | 'channel-selector' // Channel selector for Slack, Discord, etc. + | 'user-selector' // User selector for Slack, etc. | 'folder-selector' // Folder selector for Gmail, etc. | 'knowledge-base-selector' // Knowledge base selector | 'knowledge-tag-filters' // Multiple tag filters for knowledge bases @@ -85,6 +86,7 @@ export type SubBlockType = export const SELECTOR_TYPES_HYDRATION_REQUIRED: SubBlockType[] = [ 'oauth-input', 'channel-selector', + 'user-selector', 'file-selector', 'folder-selector', 'project-selector', diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 4137e3065..39844c297 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -10,6 +10,7 @@ import type { const SELECTOR_STALE = 60 * 1000 type SlackChannel = { id: string; name: string } +type SlackUser = { id: string; name: string; real_name: string } type FolderResponse = { id: string; name: string } type PlannerTask = { id: string; title: string } @@ -59,6 +60,30 @@ const registry: Record = { })) }, }, + 'slack.users': { + key: 'slack.users', + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'slack.users', + context.credentialId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.credentialId), + fetchList: async ({ context }: SelectorQueryArgs) => { + const body = JSON.stringify({ + credential: context.credentialId, + workflowId: context.workflowId, + }) + const data = await fetchJson<{ users: SlackUser[] }>('/api/tools/slack/users', { + method: 'POST', + body, + }) + return (data.users || []).map((user) => ({ + id: user.id, + label: user.real_name || user.name, + })) + }, + }, 'gmail.labels': { key: 'gmail.labels', staleTime: SELECTOR_STALE, diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts index 76e3f2117..78af03f93 100644 --- a/apps/sim/hooks/selectors/resolution.ts +++ b/apps/sim/hooks/selectors/resolution.ts @@ -32,6 +32,8 @@ export function resolveSelectorForSubBlock( return resolveFolderSelector(subBlock, args) case 'channel-selector': return resolveChannelSelector(subBlock, args) + case 'user-selector': + return resolveUserSelector(subBlock, args) case 'project-selector': return resolveProjectSelector(subBlock, args) case 'document-selector': @@ -157,6 +159,21 @@ function resolveChannelSelector( } } +function resolveUserSelector( + subBlock: SubBlockConfig, + args: SelectorResolutionArgs +): SelectorResolution { + const serviceId = subBlock.serviceId + if (serviceId !== 'slack') { + return { key: null, context: buildBaseContext(args), allowSearch: true } + } + return { + key: 'slack.users', + context: buildBaseContext(args), + allowSearch: true, + } +} + function resolveProjectSelector( subBlock: SubBlockConfig, args: SelectorResolutionArgs diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index d186c4d50..e9da5996a 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -3,6 +3,7 @@ import type { QueryKey } from '@tanstack/react-query' export type SelectorKey = | 'slack.channels' + | 'slack.users' | 'gmail.labels' | 'outlook.folders' | 'google.calendar' diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 0efdee78f..eec70eaa7 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1640,6 +1640,9 @@ export const auth = betterAuth({ 'groups:history', 'chat:write', 'chat:write.public', + 'im:write', + 'im:history', + 'im:read', 'users:read', 'files:write', 'files:read', diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 7b4f53caa..847ff59e6 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -637,6 +637,9 @@ export const OAUTH_PROVIDERS: Record = { 'groups:history', 'chat:write', 'chat:write.public', + 'im:write', + 'im:history', + 'im:read', 'users:read', 'files:write', 'files:read', diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index d61c3c344..db9401824 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -38,6 +38,57 @@ function shouldIncludeField(subBlockConfig: SubBlockConfig, isAdvancedMode: bool return true } +/** + * Evaluates a condition object against current field values. + * Used to determine if a conditionally-visible field should be included in params. + */ +function evaluateCondition( + condition: + | { + field: string + value: any + not?: boolean + and?: { field: string; value: any; not?: boolean } + } + | (() => { + field: string + value: any + not?: boolean + and?: { field: string; value: any; not?: boolean } + }) + | undefined, + values: Record +): boolean { + if (!condition) return true + + const actual = typeof condition === 'function' ? condition() : condition + const fieldValue = values[actual.field] + + const valueMatch = Array.isArray(actual.value) + ? fieldValue != null && + (actual.not ? !actual.value.includes(fieldValue) : actual.value.includes(fieldValue)) + : actual.not + ? fieldValue !== actual.value + : fieldValue === actual.value + + const andMatch = !actual.and + ? true + : (() => { + const andFieldValue = values[actual.and!.field] + const andValueMatch = Array.isArray(actual.and!.value) + ? andFieldValue != null && + (actual.and!.not + ? !actual.and!.value.includes(andFieldValue) + : actual.and!.value.includes(andFieldValue)) + : actual.and!.not + ? andFieldValue !== actual.and!.value + : andFieldValue === actual.and!.value + return andValueMatch + })() + + return valueMatch && andMatch +} + /** * Helper function to migrate agent block params from old format to messages array * Transforms systemPrompt/userPrompt into messages array format @@ -343,9 +394,15 @@ export class Serializer { const isStarterBlock = block.type === 'starter' const isAgentBlock = block.type === 'agent' - // First collect all current values from subBlocks, filtering by mode + // First pass: collect ALL raw values for condition evaluation + const allValues: Record = {} Object.entries(block.subBlocks).forEach(([id, subBlock]) => { - // Find the corresponding subblock config to check its mode + allValues[id] = subBlock.value + }) + + // Second pass: filter by mode and conditions + Object.entries(block.subBlocks).forEach(([id, subBlock]) => { + // Find the corresponding subblock config to check its mode and condition const subBlockConfig = blockConfig.subBlocks.find((config) => config.id === id) // Include field if it matches current mode OR if it's the starter inputFormat with values @@ -360,9 +417,14 @@ export class Serializer { const isLegacyAgentField = isAgentBlock && ['systemPrompt', 'userPrompt', 'memories'].includes(id) + // Check if field's condition is met (conditionally-hidden fields should be excluded) + const conditionMet = subBlockConfig + ? evaluateCondition(subBlockConfig.condition, allValues) + : true + if ( - (subBlockConfig && - (shouldIncludeField(subBlockConfig, isAdvancedMode) || hasStarterInputFormatValues)) || + (subBlockConfig && shouldIncludeField(subBlockConfig, isAdvancedMode) && conditionMet) || + hasStarterInputFormatValues || isLegacyAgentField ) { params[id] = subBlock.value @@ -475,52 +537,6 @@ export class Serializer { // Check required user-only parameters for the current tool const missingFields: string[] = [] - // Helper function to evaluate conditions - const evalCond = ( - condition: - | { - field: string - value: any - not?: boolean - and?: { field: string; value: any; not?: boolean } - } - | (() => { - field: string - value: any - not?: boolean - and?: { field: string; value: any; not?: boolean } - }) - | undefined, - values: Record - ): boolean => { - if (!condition) return true - const actual = typeof condition === 'function' ? condition() : condition - const fieldValue = values[actual.field] - - const valueMatch = Array.isArray(actual.value) - ? fieldValue != null && - (actual.not ? !actual.value.includes(fieldValue) : actual.value.includes(fieldValue)) - : actual.not - ? fieldValue !== actual.value - : fieldValue === actual.value - - const andMatch = !actual.and - ? true - : (() => { - const andFieldValue = values[actual.and!.field] - return Array.isArray(actual.and!.value) - ? andFieldValue != null && - (actual.and!.not - ? !actual.and!.value.includes(andFieldValue) - : actual.and!.value.includes(andFieldValue)) - : actual.and!.not - ? andFieldValue !== actual.and!.value - : andFieldValue === actual.and!.value - })() - - return valueMatch && andMatch - } - // Iterate through the tool's parameters, not the block's subBlocks Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => { if (paramConfig.required && paramConfig.visibility === 'user-only') { @@ -533,14 +549,14 @@ export class Serializer { const includedByMode = shouldIncludeField(subBlockConfig, isAdvancedMode) // Check visibility condition - const includedByCondition = evalCond(subBlockConfig.condition, params) + const includedByCondition = evaluateCondition(subBlockConfig.condition, params) // Check if field is required based on its required condition (if it's a condition object) const isRequired = (() => { if (!subBlockConfig.required) return false if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required // If required is a condition object, evaluate it - return evalCond(subBlockConfig.required, params) + return evaluateCondition(subBlockConfig.required, params) })() shouldValidateParam = includedByMode && includedByCondition && isRequired diff --git a/apps/sim/tools/slack/message.ts b/apps/sim/tools/slack/message.ts index 55d92bd87..d8c9e36b8 100644 --- a/apps/sim/tools/slack/message.ts +++ b/apps/sim/tools/slack/message.ts @@ -5,7 +5,7 @@ export const slackMessageTool: ToolConfig { - const url = new URL('https://slack.com/api/conversations.history') - url.searchParams.append('channel', params.channel) - // Cap limit at 15 due to Slack API restrictions for non-Marketplace apps - const limit = params.limit ? Number(params.limit) : 10 - url.searchParams.append('limit', String(Math.min(limit, 15))) - - if (params.oldest) { - url.searchParams.append('oldest', params.oldest) - } - if (params.latest) { - url.searchParams.append('latest', params.latest) - } - - return url.toString() - }, - method: 'GET', - headers: (params: SlackMessageReaderParams) => ({ + url: '/api/tools/slack/read-messages', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json', - Authorization: `Bearer ${params.accessToken || params.botToken}`, + }), + body: (params: SlackMessageReaderParams) => ({ + accessToken: params.accessToken || params.botToken, + channel: params.channel, + userId: params.userId, + limit: params.limit, + oldest: params.oldest, + latest: params.latest, }), }, transformResponse: async (response: Response) => { const data = await response.json() - if (!data.ok) { - if (data.error === 'not_in_channel') { - throw new Error( - 'Bot is not in the channel. Please invite the Sim bot to your Slack channel by typing: /invite @Sim Studio' - ) - } - if (data.error === 'channel_not_found') { - throw new Error('Channel not found. Please check the channel ID and try again.') - } - if (data.error === 'missing_scope') { - throw new Error( - 'Missing required permissions. Please reconnect your Slack account with the necessary scopes (channels:history, groups:history).' - ) - } + if (!data.success) { throw new Error(data.error || 'Failed to fetch messages from Slack') } - const messages = (data.messages || []).map((message: any) => ({ - // Core properties - type: message.type || 'message', - ts: message.ts, - text: message.text || '', - user: message.user, - bot_id: message.bot_id, - username: message.username, - channel: message.channel, - team: message.team, - - // Thread properties - thread_ts: message.thread_ts, - parent_user_id: message.parent_user_id, - reply_count: message.reply_count, - reply_users_count: message.reply_users_count, - latest_reply: message.latest_reply, - subscribed: message.subscribed, - last_read: message.last_read, - unread_count: message.unread_count, - - // Message subtype - subtype: message.subtype, - - // Reactions and interactions - reactions: message.reactions?.map((reaction: any) => ({ - name: reaction.name, - count: reaction.count, - users: reaction.users || [], - })), - is_starred: message.is_starred, - pinned_to: message.pinned_to, - - // Content attachments - files: message.files?.map((file: any) => ({ - id: file.id, - name: file.name, - mimetype: file.mimetype, - size: file.size, - url_private: file.url_private, - permalink: file.permalink, - mode: file.mode, - })), - attachments: message.attachments, - blocks: message.blocks, - - // Metadata - edited: message.edited - ? { - user: message.edited.user, - ts: message.edited.ts, - } - : undefined, - permalink: message.permalink, - })) - return { success: true, - output: { - messages, - }, + output: data.output, } }, diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index 38d3bc7ff..b6eada4c9 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -7,7 +7,8 @@ export interface SlackBaseParams { } export interface SlackMessageParams extends SlackBaseParams { - channel: string + channel?: string + userId?: string text: string thread_ts?: string files?: any[] @@ -21,7 +22,8 @@ export interface SlackCanvasParams extends SlackBaseParams { } export interface SlackMessageReaderParams extends SlackBaseParams { - channel: string + channel?: string + userId?: string limit?: number oldest?: string latest?: string