diff --git a/apps/sim/app/api/tools/slack/ephemeral-message/route.ts b/apps/sim/app/api/tools/slack/ephemeral-message/route.ts new file mode 100644 index 00000000000..315982fec38 --- /dev/null +++ b/apps/sim/app/api/tools/slack/ephemeral-message/route.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { openDMChannel, postSlackEphemeralMessage } from '../utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SlackEphemeralMessageAPI') + +const SlackEphemeralMessageSchema = z + .object({ + accessToken: z.string().min(1, 'Access token is required'), + channel: z.string().optional().nullable(), + dmUserId: z.string().optional().nullable(), + userId: z.string().min(1, 'User ID is required'), + text: z.string().min(1, 'Message text is required'), + thread_ts: z.string().optional().nullable(), + }) + .refine((data) => data.channel || data.dmUserId, { + message: 'Either channel or dmUserId is required', + }) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized Slack ephemeral send attempt: ${authResult.error}`) + return NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + }, + { status: 401 } + ) + } + + logger.info( + `[${requestId}] Authenticated Slack ephemeral send request via ${authResult.authType}`, + { + userId: authResult.userId, + } + ) + + const body = await request.json() + const validatedData = SlackEphemeralMessageSchema.parse(body) + + let channel = validatedData.channel + + if (!channel && validatedData.dmUserId) { + logger.info(`[${requestId}] Opening DM channel for user: ${validatedData.dmUserId}`) + channel = await openDMChannel( + validatedData.accessToken, + validatedData.dmUserId, + requestId, + logger + ) + } + + if (!channel) { + return NextResponse.json( + { success: false, error: 'Either channel or dmUserId is required' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Sending Slack ephemeral message`, { + channel, + targetUser: validatedData.userId, + hasThread: !!validatedData.thread_ts, + }) + + const result = await postSlackEphemeralMessage( + validatedData.accessToken, + channel, + validatedData.userId, + validatedData.text, + validatedData.thread_ts + ) + + if (!result.ok) { + logger.error(`[${requestId}] Slack API error:`, result.error) + return NextResponse.json( + { success: false, error: result.error || 'Failed to send ephemeral message' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Ephemeral message sent successfully`) + + return NextResponse.json({ + success: true, + output: { + message_ts: result.message_ts, + channel, + user: validatedData.userId, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error sending Slack ephemeral message:`, 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/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index b635c49d8fe..34cb8bc4333 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -29,6 +29,34 @@ export async function postSlackMessage( return response.json() } +/** + * Sends an ephemeral message to a Slack channel using chat.postEphemeral + * Ephemeral messages are only visible to the specified user and do not persist + */ +export async function postSlackEphemeralMessage( + accessToken: string, + channel: string, + user: string, + text: string, + threadTs?: string | null +): Promise<{ ok: boolean; message_ts?: string; error?: string }> { + const response = await fetch('https://slack.com/api/chat.postEphemeral', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + channel, + user, + text, + ...(threadTs && { thread_ts: threadTs }), + }), + }) + + return response.json() +} + /** * Creates a default message object when the API doesn't return one */ diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2bf07caeb4d..3397a700b27 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1541,6 +1541,7 @@ import { slackCanvasTool, slackDeleteMessageTool, slackDownloadTool, + slackEphemeralMessageTool, slackGetMessageTool, slackGetThreadTool, slackGetUserTool, @@ -2208,6 +2209,7 @@ export const tools: Record = { polymarket_get_holders: polymarketGetHoldersTool, slack_message: slackMessageTool, slack_message_reader: slackMessageReaderTool, + slack_ephemeral_message: slackEphemeralMessageTool, slack_list_channels: slackListChannelsTool, slack_list_members: slackListMembersTool, slack_list_users: slackListUsersTool, diff --git a/apps/sim/tools/slack/ephemeral_message.ts b/apps/sim/tools/slack/ephemeral_message.ts new file mode 100644 index 00000000000..d010f2bfddb --- /dev/null +++ b/apps/sim/tools/slack/ephemeral_message.ts @@ -0,0 +1,111 @@ +import type { SlackEphemeralMessageParams, SlackEphemeralMessageResponse } from '@/tools/slack/types' +import type { ToolConfig } from '@/tools/types' + +export const slackEphemeralMessageTool: ToolConfig< + SlackEphemeralMessageParams, + SlackEphemeralMessageResponse +> = { + id: 'slack_ephemeral_message', + name: 'Slack Ephemeral Message', + description: + 'Send ephemeral messages visible only to a specific user in Slack channels or threads. Messages are temporary and do not persist across sessions.', + version: '1.0.0', + + oauth: { + required: true, + provider: 'slack', + }, + + params: { + authMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Authentication method: oauth or bot_token', + }, + destinationType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Destination type: channel or dm', + }, + botToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Bot token for Custom Bot', + }, + accessToken: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'OAuth access token or bot token for Slack API', + }, + channel: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Slack channel ID (e.g., C1234567890)', + }, + dmUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Slack user ID for direct messages (e.g., U1234567890)', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'User ID who will see the ephemeral message (e.g., U1234567890)', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message text to send (supports Slack mrkdwn formatting)', + }, + threadTs: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Thread timestamp to reply to (creates ephemeral thread reply)', + }, + }, + + request: { + url: '/api/tools/slack/ephemeral-message', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: SlackEphemeralMessageParams) => { + const isDM = params.destinationType === 'dm' + return { + accessToken: params.accessToken || params.botToken, + channel: isDM ? undefined : params.channel, + dmUserId: isDM ? params.dmUserId : undefined, + userId: params.userId, + text: params.text, + thread_ts: params.threadTs || undefined, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to send Slack ephemeral message') + } + return { + success: true, + output: data.output, + } + }, + + outputs: { + message_ts: { type: 'string', description: 'Ephemeral message timestamp' }, + channel: { type: 'string', description: 'Channel ID where message was sent' }, + user: { type: 'string', description: 'User ID who received the ephemeral message' }, + }, +} diff --git a/apps/sim/tools/slack/index.ts b/apps/sim/tools/slack/index.ts index 2bc0f249ef6..43797d51dd9 100644 --- a/apps/sim/tools/slack/index.ts +++ b/apps/sim/tools/slack/index.ts @@ -2,6 +2,7 @@ import { slackAddReactionTool } from '@/tools/slack/add_reaction' import { slackCanvasTool } from '@/tools/slack/canvas' import { slackDeleteMessageTool } from '@/tools/slack/delete_message' import { slackDownloadTool } from '@/tools/slack/download' +import { slackEphemeralMessageTool } from '@/tools/slack/ephemeral_message' import { slackGetMessageTool } from '@/tools/slack/get_message' import { slackGetThreadTool } from '@/tools/slack/get_thread' import { slackGetUserTool } from '@/tools/slack/get_user' @@ -26,4 +27,5 @@ export { slackGetUserTool, slackGetMessageTool, slackGetThreadTool, + slackEphemeralMessageTool, } diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index 73ecccbad11..312400534bf 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -590,6 +590,15 @@ export interface SlackGetThreadParams extends SlackBaseParams { limit?: number } +export interface SlackEphemeralMessageParams extends SlackBaseParams { + destinationType?: 'channel' | 'dm' + channel?: string + dmUserId?: string + userId: string + text: string + threadTs?: string +} + export interface SlackMessageResponse extends ToolResponse { output: { // Legacy properties for backward compatibility @@ -841,6 +850,14 @@ export interface SlackGetThreadResponse extends ToolResponse { } } +export interface SlackEphemeralMessageResponse extends ToolResponse { + output: { + message_ts: string + channel: string + user: string + } +} + export type SlackResponse = | SlackCanvasResponse | SlackMessageReaderResponse @@ -855,3 +872,4 @@ export type SlackResponse = | SlackGetUserResponse | SlackGetMessageResponse | SlackGetThreadResponse + | SlackEphemeralMessageResponse