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
This commit is contained in:
Vikhyath Mondreti
2025-12-15 17:39:53 -08:00
committed by GitHub
parent bdcc42e566
commit 300aaa5368
23 changed files with 911 additions and 460 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 }
)
}
}

View File

@@ -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(

View File

@@ -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'),
})

View File

@@ -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
}

View File

@@ -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<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
*/
export async function uploadFilesToSlack(
files: any[],
accessToken: string,
requestId: string,
logger: Logger
): Promise<string[]> {
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<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
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,
},
}
}

View File

@@ -179,6 +179,9 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'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',

View File

@@ -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'

View File

@@ -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<string, any>
}
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<string | null>(null)
const [_selectedValue, setSelectedValue] = useState<string | null>(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({
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='w-full rounded border p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for service: {serviceId || 'unknown'}
{config.label} selector not supported for service: {serviceId || 'unknown'}
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>This channel selector is not yet implemented for {serviceId || 'unknown'}</p>
<p>
This {config.label.toLowerCase()} selector is not yet implemented for{' '}
{serviceId || 'unknown'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)
@@ -108,16 +127,16 @@ export function ChannelSelectorInput({
<SelectorCombobox
blockId={blockId}
subBlock={subBlock}
selectorKey='slack.channels'
selectorKey={config.selectorKey}
selectorContext={context}
disabled={finalDisabled || shouldForceDisable || isForeignCredential}
isPreview={isPreview}
previewValue={previewValue ?? null}
placeholder={subBlock.placeholder || 'Select Slack channel'}
placeholder={subBlock.placeholder || config.placeholder}
onOptionChange={(value) => {
setChannelInfo(value)
setSelectedValue(value)
if (!isPreview) {
onChannelSelect?.(value)
onSelect?.(value)
}
}}
/>

View File

@@ -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 (
<GenericSyncWrapper blockId={blockId} paramId={paramId} value={value} onChange={onChange}>
<ChannelSelectorInput
<SlackSelectorInput
blockId={blockId}
subBlock={{
id: paramId,
@@ -530,7 +530,7 @@ function ChannelSelectorSyncWrapper({
placeholder: uiComponent.placeholder,
dependsOn: uiComponent.dependsOn,
}}
onChannelSelect={onChange}
onSelect={onChange}
disabled={disabled}
previewContextValues={previewContextValues}
/>

View File

@@ -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 (
<ChannelSelectorInput
<SlackSelectorInput
blockId={blockId}
subBlock={config}
disabled={isDisabled}

View File

@@ -48,6 +48,20 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
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<SlackResponse> = {
'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<SlackResponse> = {
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<SlackResponse> = {
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<SlackResponse> = {
},
required: false,
},
// File upload (basic mode)
{
id: 'attachmentFiles',
title: 'Attachments',
@@ -149,7 +200,6 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
multiple: true,
required: false,
},
// Variable reference (advanced mode)
{
id: 'files',
title: 'File Attachments',
@@ -416,8 +466,11 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
authMethod,
botToken,
operation,
destinationType,
channel,
manualChannel,
dmUserId,
manualDmUserId,
text,
title,
content,
@@ -440,21 +493,26 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
...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<string, any> = {}
// 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<SlackResponse> = {
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<SlackResponse> = {
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)' },

View File

@@ -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',

View File

@@ -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<SelectorKey, SelectorDefinition> = {
}))
},
},
'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,

View File

@@ -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

View File

@@ -3,6 +3,7 @@ import type { QueryKey } from '@tanstack/react-query'
export type SelectorKey =
| 'slack.channels'
| 'slack.users'
| 'gmail.labels'
| 'outlook.folders'
| 'google.calendar'

View File

@@ -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',

View File

@@ -637,6 +637,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'groups:history',
'chat:write',
'chat:write.public',
'im:write',
'im:history',
'im:read',
'users:read',
'files:write',
'files:read',

View File

@@ -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<string, any>
): 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<string, any> = {}
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<string, any>
): 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

View File

@@ -5,7 +5,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
id: 'slack_message',
name: 'Slack Message',
description:
'Send messages to Slack channels or users through the Slack API. Supports Slack mrkdwn formatting.',
'Send messages to Slack channels or direct messages. Supports Slack mrkdwn formatting.',
version: '1.0.0',
oauth: {
@@ -34,10 +34,16 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
},
channel: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Target Slack channel (e.g., #general)',
},
userId: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Target Slack user ID for direct messages (e.g., U1234567890)',
},
text: {
type: 'string',
required: true,
@@ -68,6 +74,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
return {
accessToken: params.accessToken || params.botToken,
channel: params.channel,
userId: params.userId,
text: params.text,
thread_ts: params.thread_ts || undefined,
files: params.files || null,

View File

@@ -37,10 +37,16 @@ export const slackMessageReaderTool: ToolConfig<
},
channel: {
type: 'string',
required: true,
required: false,
visibility: 'user-only',
description: 'Slack channel to read messages from (e.g., #general)',
},
userId: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'User ID for DM conversation (e.g., U1234567890)',
},
limit: {
type: 'number',
required: false,
@@ -62,110 +68,31 @@ export const slackMessageReaderTool: ToolConfig<
},
request: {
url: (params: SlackMessageReaderParams) => {
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,
}
},

View File

@@ -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