mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
committed by
GitHub
parent
bdcc42e566
commit
300aaa5368
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
207
apps/sim/app/api/tools/slack/read-messages/route.ts
Normal file
207
apps/sim/app/api/tools/slack/read-messages/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
113
apps/sim/app/api/tools/slack/users/route.ts
Normal file
113
apps/sim/app/api/tools/slack/users/route.ts
Normal 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
|
||||
}
|
||||
288
apps/sim/app/api/tools/slack/utils.ts
Normal file
288
apps/sim/app/api/tools/slack/utils.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { QueryKey } from '@tanstack/react-query'
|
||||
|
||||
export type SelectorKey =
|
||||
| 'slack.channels'
|
||||
| 'slack.users'
|
||||
| 'gmail.labels'
|
||||
| 'outlook.folders'
|
||||
| 'google.calendar'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user