chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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<string, any> {
|
||||
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<string, any> {
|
||||
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<string> {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user