import type { Logger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization' import type { ToolFileData } from '@/tools/types' /** * Sends a message to a Slack channel using chat.postMessage */ async function postSlackMessage( accessToken: string, channel: string, text: string, threadTs?: string | null, blocks?: unknown[] | 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 }), ...(blocks && blocks.length > 0 && { blocks }), }), }) 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 */ async function uploadFilesToSlack( files: any[], accessToken: string, requestId: string, logger: Logger, ownerUserId: string ): Promise<{ fileIds: string[]; files: ToolFileData[] }> { const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] const uploadedFiles: ToolFileData[] = [] for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) const hasAccess = await verifyFileAccess(userFile.key, ownerUserId) if (!hasAccess) { throw new FileAccessDeniedError() } const { buffer, contentType } = await downloadServableFileFromStorage( 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 secureFetchWithValidation( urlData.upload_url, { method: 'POST', body: buffer, }, 'uploadUrl' ) 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) // Only add to uploadedFiles after successful upload to keep arrays in sync uploadedFiles.push({ name: userFile.name, mimeType: contentType || userFile.type || 'application/octet-stream', data: buffer.toString('base64'), size: buffer.length, }) } return { fileIds: uploadedFileIds, files: uploadedFiles } } /** * Completes the file upload process by associating files with a channel */ async function completeSlackFileUpload( uploadedFileIds: string[], channel: string, text: string, accessToken: string, threadTs?: string | null, blocks?: unknown[] | null ): 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, // Per Slack docs for files.completeUploadExternal: if `initial_comment` // is provided, `blocks` is silently ignored. So when blocks are present // we omit initial_comment and let blocks render instead. ...(blocks && blocks.length > 0 ? { blocks } : { initial_comment: text }), ...(threadTs && { thread_ts: threadTs }), }), }) 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 ownerUserId: string text: string threadTs?: string | null blocks?: unknown[] | 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 files?: ToolFileData[] } error?: string }> { const { accessToken, text, threadTs, blocks, files, ownerUserId } = 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, blocks) 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 { fileIds, files: uploadedFiles } = await uploadFilesToSlack( files, accessToken, requestId, logger, ownerUserId ) // No valid files uploaded - send text-only if (fileIds.length === 0) { logger.warn(`[${requestId}] No valid files to upload, sending text-only message`) const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks) if (!data.ok) { return { success: false, error: data.error || 'Failed to send message' } } return { success: true, output: formatMessageSuccessResponse(data, text) } } // Complete file upload with thread support const completeData = await completeSlackFileUpload( fileIds, channel, text, accessToken, threadTs, blocks ) 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: fileIds.length, files: uploadedFiles, }, } }