From 4293470ed51f52d824fe2d8fc30c86a464709af9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 May 2025 13:37:32 -0700 Subject: [PATCH 01/10] fix formatting of contributors chart --- apps/sim/app/(landing)/contributors/page.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/(landing)/contributors/page.tsx b/apps/sim/app/(landing)/contributors/page.tsx index 17ee25040d2..006749e89d7 100644 --- a/apps/sim/app/(landing)/contributors/page.tsx +++ b/apps/sim/app/(landing)/contributors/page.tsx @@ -433,7 +433,7 @@ export default function ContributorsPage() { - - {payload.value.length > 6 - ? `${payload.value.slice(0, 6)}...` - : payload.value} - ) }} - height={60} - className='sm:!h-[80px] text-neutral-400' + height={50} + className='sm:!h-[60px] text-neutral-400' /> Date: Sat, 31 May 2025 20:21:16 -0700 Subject: [PATCH 02/10] added slack oauth, removed hardcoded localhosts and use NEXT_PUBLIC_APP_URL instead --- apps/sim/app/api/chat/route.ts | 16 +- .../sim/app/api/tools/slack/channels/route.ts | 104 +++++++++ .../channel-selector-input.tsx | 105 +++++++++ .../components/slack-channel-selector.tsx | 213 ++++++++++++++++++ .../components/oauth-required-modal.tsx | 8 + .../components/tool-input/tool-input.tsx | 184 ++++++++++++--- .../components/sub-block/sub-block.tsx | 3 + apps/sim/blocks/blocks/slack.ts | 127 +++++++++-- apps/sim/blocks/types.ts | 1 + apps/sim/lib/auth-client.ts | 10 +- apps/sim/lib/auth.ts | 65 ++++++ apps/sim/lib/env.ts | 2 + apps/sim/lib/oauth.ts | 42 ++++ apps/sim/lib/urls/utils.ts | 11 +- apps/sim/middleware.ts | 46 +++- apps/sim/next.config.ts | 11 +- apps/sim/providers/ollama/index.ts | 5 + apps/sim/tools/http/request.ts | 3 +- apps/sim/tools/slack/message.ts | 37 ++- apps/sim/tools/slack/types.ts | 10 +- 20 files changed, 918 insertions(+), 85 deletions(-) create mode 100644 apps/sim/app/api/tools/slack/channels/route.ts create mode 100644 apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx create mode 100644 apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index c63e0285775..662e402e673 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -170,9 +170,19 @@ export async function POST(request: NextRequest) { // Return successful response with chat URL // Check if we're in development or production const isDevelopment = env.NODE_ENV === 'development' - const chatUrl = isDevelopment - ? `http://${subdomain}.localhost:3000` - : `https://${subdomain}.simstudio.ai` + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + + let chatUrl: string + if (isDevelopment) { + try { + const url = new URL(baseUrl) + chatUrl = `${url.protocol}//${subdomain}.${url.host}` + } catch { + chatUrl = `http://${subdomain}.localhost:3000` + } + } else { + chatUrl = `https://${subdomain}.simstudio.ai` + } logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts new file mode 100644 index 00000000000..47daacd52a7 --- /dev/null +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('SlackChannelsAPI') + +interface SlackChannel { + id: string + name: string + is_private: boolean + is_archived: boolean + is_member: boolean +} + +export async function POST(request: Request) { + try { + const session = await getSession() + 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 + + // Check if the credential is a bot token (starts with 'xoxb-') + if (credential.startsWith('xoxb-')) { + // Direct bot token + accessToken = credential + logger.info('Using direct bot token for Slack API') + } else { + // OAuth credential - need to resolve it + const userId = session?.user?.id || '' + if (!userId) { + logger.error('No user ID found in session') + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId) + if (!resolvedToken) { + logger.error('Failed to get access token', { credentialId: credential, userId }) + return NextResponse.json( + { + error: 'Could not retrieve access token', + authRequired: true, + }, + { status: 401 } + ) + } + accessToken = resolvedToken + logger.info('Using OAuth token for Slack API') + } + + // Fetch channels from Slack API + const response = await fetch('https://slack.com/api/conversations.list', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('Slack API error:', { + status: response.status, + statusText: response.statusText, + }) + return NextResponse.json( + { error: `Slack API error: ${response.status} ${response.statusText}` }, + { status: response.status } + ) + } + + const data = await response.json() + + if (!data.ok) { + logger.error('Slack API returned error:', data.error) + return NextResponse.json({ error: data.error || 'Failed to fetch channels' }, { status: 400 }) + } + + // Filter to channels the bot can access and format the response + const channels = data.channels + .filter((channel: SlackChannel) => !channel.is_archived && channel.is_member) + .map((channel: SlackChannel) => ({ + id: channel.id, + name: channel.name, + isPrivate: channel.is_private, + })) + + logger.info(`Successfully fetched ${channels.length} Slack channels`) + return NextResponse.json({ channels }) + } catch (error) { + logger.error('Error processing Slack channels request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Slack channels', details: (error as Error).message }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx new file mode 100644 index 00000000000..d480073e8c0 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import type { SubBlockConfig } from '@/blocks/types' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { type SlackChannelInfo, SlackChannelSelector } from './components/slack-channel-selector' + +interface ChannelSelectorInputProps { + blockId: string + subBlock: SubBlockConfig + disabled?: boolean + onChannelSelect?: (channelId: string) => void + credential?: string // Optional credential override +} + +export function ChannelSelectorInput({ + blockId, + subBlock, + disabled = false, + onChannelSelect, + credential: providedCredential, +}: ChannelSelectorInputProps) { + const { getValue, setValue } = useSubBlockStore() + const [selectedChannelId, setSelectedChannelId] = useState('') + const [_channelInfo, setChannelInfo] = useState(null) + + // Get provider-specific values + const provider = subBlock.provider || 'slack' + const isSlack = provider === 'slack' + + // Get the credential for the provider - use provided credential or fall back to store + const authMethod = getValue(blockId, 'authMethod') as string + const botToken = getValue(blockId, 'botToken') as string + + let credential: string + if (providedCredential) { + credential = providedCredential + } else if (authMethod === 'bot_token' && botToken) { + credential = botToken + } else { + credential = (getValue(blockId, 'credential') as string) || '' + } + + // Get the current value from the store + useEffect(() => { + const value = getValue(blockId, subBlock.id) + if (value && typeof value === 'string') { + setSelectedChannelId(value) + } + }, [blockId, subBlock.id, getValue]) + + // Handle channel selection + const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => { + setSelectedChannelId(channelId) + setChannelInfo(info || null) + setValue(blockId, subBlock.id, channelId) + onChannelSelect?.(channelId) + } + + // Render Slack channel selector + if (isSlack) { + return ( + + + +
+ { + handleChannelChange(channelId, channelInfo) + }} + credential={credential} + label={subBlock.placeholder || 'Select Slack channel'} + disabled={disabled || !credential} + showPreview={true} + /> +
+
+ {!credential && ( + +

Please select a Slack account or enter a bot token first

+
+ )} +
+
+ ) + } + + // Default fallback for unsupported providers + return ( + + + +
+ Channel selector not supported for provider: {provider} +
+
+ +

This channel selector is not yet implemented for {provider}

+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx new file mode 100644 index 00000000000..ed52d577783 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, Hash, Lock, RefreshCw } from 'lucide-react' +import { SlackIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' + +export interface SlackChannelInfo { + id: string + name: string + isPrivate: boolean +} + +interface SlackChannelSelectorProps { + value: string + onChange: (channelId: string, channelInfo?: SlackChannelInfo) => void + credential: string + label?: string + disabled?: boolean + showPreview?: boolean +} + +export function SlackChannelSelector({ + value, + onChange, + credential, + label = 'Select Slack channel', + disabled = false, +}: SlackChannelSelectorProps) { + const [channels, setChannels] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const [selectedChannel, setSelectedChannel] = useState(null) + const [initialFetchDone, setInitialFetchDone] = useState(false) + + // Fetch channels from Slack API + const fetchChannels = useCallback(async () => { + if (!credential) return + + const controller = new AbortController() + setLoading(true) + setError(null) + + try { + const res = await fetch('/api/tools/slack/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }), + signal: controller.signal, + }) + + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) + + const data = await res.json() + if (data.error) { + setError(data.error) + setChannels([]) + } else { + setChannels(data.channels) + setInitialFetchDone(true) + + // Find selected channel info if we have a value + if (value) { + const channelInfo = data.channels.find((c: SlackChannelInfo) => c.id === value) + setSelectedChannel(channelInfo || null) + } + } + } catch (err) { + if ((err as Error).name === 'AbortError') return + setError((err as Error).message) + setChannels([]) + } finally { + setLoading(false) + } + }, [credential, value]) + + // Handle dropdown open/close - fetch channels when opening + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + + // Only fetch channels when opening the dropdown and if we have valid credential + if (isOpen && credential && (!initialFetchDone || channels.length === 0)) { + fetchChannels() + } + } + + // Sync selected channel with value prop + useEffect(() => { + if (value && channels.length > 0) { + const channelInfo = channels.find((c) => c.id === value) + setSelectedChannel(channelInfo || null) + } else if (!value) { + setSelectedChannel(null) + } + }, [value, channels]) + + // If we have a value but no channel info and haven't fetched yet, get just that channel + useEffect(() => { + if (value && !selectedChannel && !loading && !initialFetchDone && credential) { + // For now, we'll fetch all channels when needed + // In the future, we could optimize to fetch just the selected channel + fetchChannels() + } + }, [value, selectedChannel, loading, initialFetchDone, credential, fetchChannels]) + + const handleSelectChannel = (channel: SlackChannelInfo) => { + setSelectedChannel(channel) + onChange(channel.id, channel) + setOpen(false) + } + + const getChannelIcon = (channel: SlackChannelInfo) => { + return channel.isPrivate ? : + } + + const formatChannelName = (channel: SlackChannelInfo) => { + return channel.isPrivate ? channel.name : `${channel.name}` + } + + return ( + + + + + + + + + + {loading ? ( +
+ + Loading channels... +
+ ) : error ? ( +
+

{error}

+
+ ) : !credential ? ( +
+

Missing credentials

+

+ Please configure Slack credentials. +

+
+ ) : ( +
+

No channels found

+

+ No channels available for this Slack workspace. +

+
+ )} +
+ + {channels.length > 0 && ( + +
+ Channels +
+ {channels.map((channel) => ( + handleSelectChannel(channel)} + className='cursor-pointer' + > +
+ + {getChannelIcon(channel)} + {formatChannelName(channel)} + {channel.isPrivate && ( + Private + )} +
+ {channel.id === value && } +
+ ))} +
+ )} +
+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 6e830026b91..de67a3487dc 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -108,6 +108,14 @@ const SCOPE_DESCRIPTIONS: Record = { 'guilds.members.read': 'Read your Discord guild members', read: 'Read access to your Linear workspace', write: 'Write access to your Linear workspace', + 'channels:read': 'Read your Slack channels', + 'chat:write': 'Write to your Slack channels', + 'chat:write.public': 'Write to your Slack channels', + 'users:read': 'Read your Slack users', + 'search:read': 'Read your Slack search', + 'files:read': 'Read your Slack files', + 'links:read': 'Read your Slack links', + 'links:write': 'Write to your Slack links', } // Convert OAuth scope to user-friendly description diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index f72df59d769..ab5b6306856 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -11,7 +11,6 @@ import { } from '@/components/ui/select' import { Toggle } from '@/components/ui/toggle' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { createLogger } from '@/lib/logs/console-logger' import type { OAuthProvider } from '@/lib/oauth' import { cn } from '@/lib/utils' import { getAllBlocks } from '@/blocks' @@ -23,13 +22,12 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { getTool } from '@/tools/utils' import { useSubBlockValue } from '../../hooks/use-sub-block-value' +import { ChannelSelectorInput } from '../channel-selector/channel-selector-input' import { CredentialSelector } from '../credential-selector/credential-selector' import { ShortInput } from '../short-input' import { type CustomTool, CustomToolModal } from './components/custom-tool-modal/custom-tool-modal' import { ToolCommand } from './components/tool-command/tool-command' -const _logger = createLogger('ToolInput') - interface ToolInputProps { blockId: string subBlockId: string @@ -240,6 +238,54 @@ const formatParamId = (paramId: string): string => { return paramId.charAt(0).toUpperCase() + paramId.slice(1) } +// Helper function to check if a parameter should use a channel selector +const shouldUseChannelSelector = (blockType: string, paramId: string): boolean => { + const block = getAllBlocks().find((block) => block.type === blockType) + if (!block) return false + + // Look for a subBlock with the same ID that has type 'channel-selector' + const subBlock = block.subBlocks.find((sb) => sb.id === paramId) + return subBlock?.type === 'channel-selector' +} + +// Helper function to get channel selector configuration from block definition +const getChannelSelectorConfig = (blockType: string, paramId: string) => { + const block = getAllBlocks().find((block) => block.type === blockType) + if (!block) return null + + const subBlock = block.subBlocks.find((sb) => sb.id === paramId && sb.type === 'channel-selector') + return subBlock || null +} + +// Helper function to check if a parameter should be treated as a password field +const shouldBePasswordField = (blockType: string, paramId: string): boolean => { + // Check if the block configuration explicitly sets password: true for this parameter + const block = getAllBlocks().find((block) => block.type === blockType) + if (block) { + const subBlock = block.subBlocks.find((sb) => sb.id === paramId) + if (subBlock?.password) { + return true + } + } + + // Fallback: check for common password/API key patterns + const normalizedId = paramId.toLowerCase().replace(/\s+/g, '') + const passwordPatterns = [ + 'apikey', + 'api_key', + 'secretkey', + 'secret_key', + 'token', + 'bottoken', + 'accesstoken', + 'authtoken', + 'password', + 'secret', + ] + + return passwordPatterns.some((pattern) => normalizedId.includes(pattern)) +} + export function ToolInput({ blockId, subBlockId }: ToolInputProps) { const [value, setValue] = useSubBlockValue(blockId, subBlockId) const [open, setOpen] = useState(false) @@ -817,9 +863,12 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { - {tool.isExpanded && !isCustomTool && isExpandable && ( + {!isCustomTool && isExpandable && (
{ if (e.target === e.currentTarget) { toggleToolExpansion(toolIndex) @@ -873,34 +922,105 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { })()} {/* Existing parameters */} - {requiredParams.map((param) => ( -
-
- {formatParamId(param.id)} - {param.optionalToolInput && !param.requiredForToolCall && ( - - (Optional) - - )} -
-
- handleParamChange(toolIndex, param.id, value)} - /> + {requiredParams.map((param) => { + // Check if this parameter should use a channel selector + const useChannelSelector = + !isCustomTool && shouldUseChannelSelector(tool.type, param.id) + const channelSelectorConfig = useChannelSelector + ? getChannelSelectorConfig(tool.type, param.id) + : null + + // Smart conditional rendering for Slack authentication parameters + if (tool.type === 'slack') { + const botToken = + tool.params.botToken || + (subBlockStore.getValue(blockId, 'botToken') as string) + const oauthCredential = + tool.params.credential || + (subBlockStore.getValue(blockId, 'credential') as string) + + // If this is the credential parameter (OAuth) + if (param.id === 'credential') { + if (botToken?.trim()) { + return null + } + } + + // If this is the botToken parameter + if (param.id === 'botToken') { + if (oauthCredential?.trim()) { + return null + } + } + } + + // Determine the correct credential to pass for channel selector + let credentialForChannelSelector = '' + if (useChannelSelector) { + const botToken = + tool.params.botToken || + (subBlockStore.getValue(blockId, 'botToken') as string) + const oauthCredential = + tool.params.credential || + (subBlockStore.getValue(blockId, 'credential') as string) + + if (botToken?.trim()) { + credentialForChannelSelector = botToken + } else if (oauthCredential?.trim()) { + credentialForChannelSelector = oauthCredential + } + } + + return ( +
+
+ {formatParamId(param.id)} + {param.optionalToolInput && !param.requiredForToolCall && ( + + (Optional) + + )} +
+
+ {useChannelSelector && channelSelectorConfig ? ( + { + handleParamChange(toolIndex, param.id, channelId) + }} + /> + ) : ( + + handleParamChange(toolIndex, param.id, value) + } + /> + )} +
-
- ))} + ) + })}
)}
diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index 265cb271725..595ae155d28 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -5,6 +5,7 @@ import { getBlock } from '@/blocks/index' import type { SubBlockConfig } from '@/blocks/types' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { ChannelSelectorInput } from './components/channel-selector/channel-selector-input' import { CheckboxList } from './components/checkbox-list' import { Code } from './components/code' import { ConditionInput } from './components/condition-input' @@ -180,6 +181,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) { return case 'project-selector': return + case 'channel-selector': + return case 'folder-selector': return case 'input-format': diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index ae49e07f55f..c57c928d157 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -2,48 +2,139 @@ import { SlackIcon } from '@/components/icons' import type { SlackMessageResponse } from '@/tools/slack/types' import type { BlockConfig } from '../types' -export const SlackBlock: BlockConfig = { +type SlackResponse = SlackMessageResponse + +export const SlackBlock: BlockConfig = { type: 'slack', name: 'Slack', - description: 'Send a message to Slack', + description: 'Send messages to Slack', longDescription: - 'Send messages to any Slack channel using OAuth authentication. Integrate automated notifications and alerts into your workflow to keep your team informed.', + "Comprehensive Slack integration with OAuth authentication. Send formatted messages using Slack's mrkdwn syntax or Block Kit.", docsLink: 'https://docs.simstudio.ai/tools/slack', category: 'tools', bgColor: '#611f69', icon: SlackIcon, subBlocks: [ { - id: 'channel', - title: 'Channel', - type: 'short-input', + id: 'operation', + title: 'Operation', + type: 'dropdown', layout: 'full', - placeholder: 'Enter Slack channel (e.g., #general)', + options: [{ label: 'Send Message', id: 'send' }], + value: () => 'send', }, { - id: 'text', - title: 'Message', - type: 'long-input', + id: 'authMethod', + title: 'Authentication Method', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Sim Studio Bot', id: 'oauth' }, + { label: 'Custom Bot', id: 'bot_token' }, + ], + value: () => 'oauth', + }, + { + id: 'credential', + title: 'Slack Account', + type: 'oauth-input', layout: 'full', - placeholder: 'Enter your alert message', + provider: 'slack', + serviceId: 'slack', + requiredScopes: [ + 'channels:read', + 'chat:write', + 'chat:write.public', + 'users:read', + 'files:read', + 'links:read', + 'links:write', + ], + placeholder: 'Select Slack workspace', + condition: { + field: 'authMethod', + value: 'oauth', + }, }, { - id: 'apiKey', - title: 'OAuth Token', + id: 'botToken', + title: 'Bot Token', type: 'short-input', layout: 'full', - placeholder: 'Enter your Slack OAuth token', + placeholder: 'Enter your Slack bot token (xoxb-...)', password: true, - connectionDroppable: false, + condition: { + field: 'authMethod', + value: 'bot_token', + }, + }, + { + id: 'channel', + title: 'Channel', + type: 'channel-selector', + layout: 'full', + provider: 'slack', + placeholder: 'Select Slack channel', + condition: { + field: 'operation', + value: ['send'], + }, + }, + { + id: 'text', + title: 'Message', + type: 'long-input', + layout: 'full', + placeholder: 'Enter your message (supports Slack mrkdwn)', + condition: { + field: 'operation', + value: ['send'], + }, }, ], tools: { access: ['slack_message'], + config: { + tool: (params) => { + switch (params.operation) { + case 'send': + return 'slack_message' + default: + throw new Error(`Invalid Slack operation: ${params.operation}`) + } + }, + params: (params) => { + const { credential, authMethod, botToken, operation, ...rest } = params + + const baseParams = { + ...rest, + } + + // Handle authentication based on method + if (authMethod === 'bot_token') { + if (!botToken) { + throw new Error('Bot token is required when using bot token authentication') + } + baseParams.accessToken = botToken + } else { + // Default to OAuth + if (!credential) { + throw new Error('Slack account credential is required when using Sim Studio Bot') + } + baseParams.credential = credential + } + + return baseParams + }, + }, }, inputs: { - apiKey: { type: 'string', required: true }, - channel: { type: 'string', required: true }, - text: { type: 'string', required: true }, + operation: { type: 'string', required: true }, + authMethod: { type: 'string', required: true }, + credential: { type: 'string', required: false }, + botToken: { type: 'string', required: false }, + channel: { type: 'string', required: false }, + text: { type: 'string', required: false }, }, outputs: { response: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 1a4b65fed49..45ecc48de3f 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -29,6 +29,7 @@ export type SubBlockType = | 'schedule-config' // Schedule status and information | 'file-selector' // File selector for Google Drive, etc. | 'project-selector' // Project selector for Jira, Discord, etc. + | 'channel-selector' // Channel selector for Slack, Discord, etc. | 'folder-selector' // Folder selector for Gmail, etc. | 'input-format' // Input structure format | 'file-upload' // File uploader diff --git a/apps/sim/lib/auth-client.ts b/apps/sim/lib/auth-client.ts index bc1bc5f47b9..854941ac74f 100644 --- a/apps/sim/lib/auth-client.ts +++ b/apps/sim/lib/auth-client.ts @@ -4,9 +4,10 @@ import { createAuthClient } from 'better-auth/react' const clientEnv = { NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NODE_ENV: process.env.NODE_ENV, VERCEL_ENV: process.env.VERCEL_ENV || '', - BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || 'http://localhost:3000', + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, } export function getBaseURL() { @@ -17,11 +18,14 @@ export function getBaseURL() { } else if (clientEnv.VERCEL_ENV === 'development') { baseURL = `https://${clientEnv.NEXT_PUBLIC_VERCEL_URL}` } else if (clientEnv.VERCEL_ENV === 'production') { - baseURL = clientEnv.BETTER_AUTH_URL + baseURL = clientEnv.BETTER_AUTH_URL || clientEnv.NEXT_PUBLIC_APP_URL } else if (clientEnv.NODE_ENV === 'development') { - baseURL = clientEnv.BETTER_AUTH_URL + // For development, prioritize NEXT_PUBLIC_APP_URL for client-side requests + baseURL = clientEnv.NEXT_PUBLIC_APP_URL || clientEnv.BETTER_AUTH_URL || 'http://localhost:3000' } + console.log('baseURL', baseURL) + return baseURL } diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index f837d41c010..222ef9395b0 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -116,6 +116,7 @@ export const auth = betterAuth({ 'x', 'notion', 'microsoft', + 'slack', ], }, }, @@ -864,6 +865,70 @@ export const auth = betterAuth({ } }, }, + + // Slack provider + { + providerId: 'slack', + clientId: env.SLACK_CLIENT_ID as string, + clientSecret: env.SLACK_CLIENT_SECRET as string, + authorizationUrl: 'https://slack.com/oauth/v2/authorize', + tokenUrl: 'https://slack.com/api/oauth.v2.access', + userInfoUrl: 'https://slack.com/api/users.identity', + scopes: [ + // Bot token scopes only - app acts as a bot user + 'channels:read', + 'chat:write', + 'chat:write.public', + 'files:read', + 'links:read', + 'links:write', + 'users:read', + ], + responseType: 'code', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/slack`, + getUserInfo: async (tokens) => { + try { + logger.info('Creating Slack bot profile from token data') + + // Extract user identifier from tokens if possible + let userId = 'slack-bot' + if (tokens.idToken) { + try { + // Try to decode the JWT to get user information + const decodedToken = JSON.parse( + Buffer.from(tokens.idToken.split('.')[1], 'base64').toString() + ) + if (decodedToken.sub) { + userId = decodedToken.sub + } + } catch (e) { + logger.warn('Failed to decode Slack ID token', { error: e }) + } + } + + // Generate a unique enough identifier + const uniqueId = `${userId}-${Date.now()}` + + const now = new Date() + + // Create a synthetic user profile since we can't fetch one + return { + id: uniqueId, + name: 'Slack Bot', + email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@slack.bot`, + image: null, + emailVerified: false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error creating Slack bot profile:', { error }) + return null + } + }, + }, ], }), // Only include the Stripe plugin in production diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index c66eb8f79c2..77702e423f8 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -99,6 +99,8 @@ export const env = createEnv({ DOCKER_BUILD: z.boolean().optional(), LINEAR_CLIENT_ID: z.string().optional(), LINEAR_CLIENT_SECRET: z.string().optional(), + SLACK_CLIENT_ID: z.string().optional(), + SLACK_CLIENT_SECRET: z.string().optional(), }, client: { diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index 994b86bb0d0..4cc8a7001af 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -17,6 +17,7 @@ import { MicrosoftTeamsIcon, NotionIcon, OutlookIcon, + SlackIcon, SupabaseIcon, xIcon, } from '@/components/icons' @@ -38,6 +39,7 @@ export type OAuthProvider = | 'discord' | 'microsoft' | 'linear' + | 'slack' | string export type OAuthService = @@ -58,6 +60,8 @@ export type OAuthService = | 'microsoft-teams' | 'outlook' | 'linear' + | 'slack' + // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { id: OAuthProvider @@ -361,6 +365,31 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'linear', }, + slack: { + id: 'slack', + name: 'Slack', + icon: (props) => SlackIcon(props), + services: { + slack: { + id: 'slack', + name: 'Slack', + description: 'Send messages, search conversations, and manage Slack workspaces.', + providerId: 'slack', + icon: (props) => SlackIcon(props), + baseProviderIcon: (props) => SlackIcon(props), + scopes: [ + 'channels:read', + 'chat:write', + 'chat:write.public', + 'users:read', + 'files:read', + 'links:read', + 'links:write', + ], + }, + }, + defaultService: 'slack', + }, } // Helper function to get a service by provider and service ID @@ -427,6 +456,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return 'discord' } else if (provider === 'linear') { return 'linear' + } else if (provider === 'slack') { + return 'slack' } return providerConfig.defaultService @@ -574,6 +605,17 @@ export async function refreshOAuthToken( clientId = env.MICROSOFT_CLIENT_ID clientSecret = env.MICROSOFT_CLIENT_SECRET break + case 'linear': + tokenEndpoint = 'https://api.linear.app/oauth/token' + clientId = env.LINEAR_CLIENT_ID + clientSecret = env.LINEAR_CLIENT_SECRET + useBasicAuth = true + break + case 'slack': + tokenEndpoint = 'https://slack.com/api/oauth.v2.access' + clientId = env.SLACK_CLIENT_ID + clientSecret = env.SLACK_CLIENT_SECRET + break default: throw new Error(`Unsupported provider: ${provider}`) } diff --git a/apps/sim/lib/urls/utils.ts b/apps/sim/lib/urls/utils.ts index 2683d9e6d3d..9c727bcd699 100644 --- a/apps/sim/lib/urls/utils.ts +++ b/apps/sim/lib/urls/utils.ts @@ -18,7 +18,7 @@ export function getBaseUrl(): string { return `${protocol}${baseUrl}` } - return 'http://localhost:3000' + return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' } /** @@ -30,7 +30,12 @@ export function getBaseDomain(): string { const url = new URL(getBaseUrl()) return url.host // host includes port if specified } catch (_e) { - const isProd = process.env.NODE_ENV === 'production' - return isProd ? 'simstudio.ai' : 'localhost:3000' + const fallbackUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + try { + return new URL(fallbackUrl).host + } catch { + const isProd = process.env.NODE_ENV === 'production' + return isProd ? 'simstudio.ai' : 'localhost:3000' + } } } diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index ea20d7affaa..5fe1dadeaf3 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -24,18 +24,46 @@ export async function middleware(request: NextRequest) { const sessionCookie = getSessionCookie(request) const hasActiveSession = !!sessionCookie - // Check if user has previously logged in by checking localStorage value in cookies - const _hasPreviouslyLoggedIn = request.cookies.get('has_logged_in_before')?.value === 'true' - const url = request.nextUrl const hostname = request.headers.get('host') || '' - // Extract subdomain - const isCustomDomain = - hostname !== BASE_DOMAIN && - !hostname.startsWith('www.') && - hostname.includes(isDevelopment ? 'localhost' : 'simstudio.ai') - const subdomain = isCustomDomain ? hostname.split('.')[0] : null + let isCustomDomain = false + let subdomain: string | null = null + + try { + const baseDomainUrl = new URL(`http://${BASE_DOMAIN}`) + const baseDomainHost = baseDomainUrl.hostname + const baseDomainPort = baseDomainUrl.port + + if (hostname !== BASE_DOMAIN && !hostname.startsWith('www.')) { + if (isDevelopment && baseDomainHost === 'localhost') { + const hostnameParts = hostname.split('.') + if (hostnameParts.length >= 2 && hostnameParts[1] === 'localhost') { + const lastPart = hostnameParts[hostnameParts.length - 1] + const hasPort = lastPart.includes(':') + const port = hasPort ? lastPart.split(':')[1] : null + + if (!baseDomainPort || !hasPort || port === baseDomainPort) { + isCustomDomain = true + subdomain = hostnameParts[0] + } + } + } else if (!isDevelopment && hostname.endsWith('.simstudio.ai')) { + const hostnameParts = hostname.split('.') + if (hostnameParts.length >= 3) { + isCustomDomain = true + subdomain = hostnameParts[0] + } + } + } + } catch (error) { + logger.warn('Error parsing base domain for subdomain detection:', error) + isCustomDomain = + hostname !== BASE_DOMAIN && + !hostname.startsWith('www.') && + hostname.includes(isDevelopment ? 'localhost' : 'simstudio.ai') + subdomain = isCustomDomain ? hostname.split('.')[0] : null + } // Handle chat subdomains if (subdomain && isCustomDomain) { diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 415cfd35b4a..2bf062cc994 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -25,6 +25,13 @@ const nextConfig: NextConfig = { experimental: { optimizeCss: true, }, + ...(env.NODE_ENV === 'development' && { + allowedDevOrigins: [ + ...(process.env.NEXT_PUBLIC_APP_URL ? [new URL(process.env.NEXT_PUBLIC_APP_URL).host] : []), + 'localhost:3000', + 'localhost:3001', + ], + }), ...(env.NODE_ENV === 'development' && { outputFileTracingRoot: path.join(__dirname, '../../'), }), @@ -68,7 +75,7 @@ const nextConfig: NextConfig = { { key: 'Access-Control-Allow-Credentials', value: 'true' }, { key: 'Access-Control-Allow-Origin', - value: 'https://localhost:3001', + value: process.env.NEXT_PUBLIC_APP_URL || 'https://localhost:3001', }, { key: 'Access-Control-Allow-Methods', @@ -138,7 +145,7 @@ const nextConfig: NextConfig = { }, { key: 'Content-Security-Policy', - value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://*.vercel-scripts.com https://*.vercel-insights.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${env.OLLAMA_URL || 'http://localhost:11434'} https://api.browser-use.com https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app; frame-src https://drive.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'`, + value: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://*.vercel-scripts.com https://*.vercel-insights.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${process.env.NEXT_PUBLIC_APP_URL || ''} ${env.OLLAMA_URL || 'http://localhost:11434'} https://api.browser-use.com https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app; frame-src https://drive.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'`, }, ], }, diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index cdacdca27ee..e71c1de4207 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -19,6 +19,11 @@ export const ollamaProvider: ProviderConfig = { // Initialize the provider by fetching available models async initialize() { + if (typeof window !== 'undefined') { + logger.info('Skipping Ollama initialization on client side to avoid CORS issues') + return + } + try { const response = await fetch(`${OLLAMA_HOST}/api/tags`) if (!response.ok) { diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 553ec7f56aa..3e96c9c3f8a 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -6,7 +6,6 @@ import type { RequestParams, RequestResponse } from './types' const logger = createLogger('HTTPRequestTool') -// Function to get the appropriate referer based on environment const getReferer = (): string => { if (typeof window !== 'undefined') { return window.location.origin @@ -15,7 +14,7 @@ const getReferer = (): string => { try { return getBaseUrl() } catch (_error) { - return 'http://localhost:3000' + return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' } } diff --git a/apps/sim/tools/slack/message.ts b/apps/sim/tools/slack/message.ts index b8a012f0eb4..62044cbe62f 100644 --- a/apps/sim/tools/slack/message.ts +++ b/apps/sim/tools/slack/message.ts @@ -5,15 +5,26 @@ export const slackMessageTool: ToolConfig ({ 'Content-Type': 'application/json', - Authorization: `Bearer ${params.apiKey}`, - }), - body: (params: SlackMessageParams) => ({ - channel: params.channel, - text: params.text, + Authorization: `Bearer ${params.accessToken || params.botToken}`, }), + body: (params: SlackMessageParams) => { + const body: any = { + channel: params.channel, + markdown_text: params.text, + } + + return body + }, }, transformResponse: async (response: Response) => { diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index f98dd2f9cf3..767cd06d22a 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -1,9 +1,15 @@ import type { ToolResponse } from '../types' -export interface SlackMessageParams { - apiKey: string +export interface SlackBaseParams { + authMethod: string + accessToken: string + botToken: string +} + +export interface SlackMessageParams extends SlackBaseParams { channel: string text: string + thread_ts?: string } export interface SlackMessageResponse extends ToolResponse { From 8a06a3668f5477d3156f05fa09f27a68a69c6d8d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 May 2025 20:54:49 -0700 Subject: [PATCH 03/10] remove conditional rendering of subblocks for tools in an agent blcok --- .../components/tool-input/tool-input.tsx | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index ab5b6306856..4626184f5de 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -930,30 +930,6 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { ? getChannelSelectorConfig(tool.type, param.id) : null - // Smart conditional rendering for Slack authentication parameters - if (tool.type === 'slack') { - const botToken = - tool.params.botToken || - (subBlockStore.getValue(blockId, 'botToken') as string) - const oauthCredential = - tool.params.credential || - (subBlockStore.getValue(blockId, 'credential') as string) - - // If this is the credential parameter (OAuth) - if (param.id === 'credential') { - if (botToken?.trim()) { - return null - } - } - - // If this is the botToken parameter - if (param.id === 'botToken') { - if (oauthCredential?.trim()) { - return null - } - } - } - // Determine the correct credential to pass for channel selector let credentialForChannelSelector = '' if (useChannelSelector) { From 43ca17e336662df4c47891d0dbf9d5bbe61243dc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 31 May 2025 22:08:48 -0700 Subject: [PATCH 04/10] updated tests --- apps/sim/app/api/chat/route.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index af8feae858a..54cb8337479 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -5,7 +5,6 @@ import { NextRequest } from 'next/server' * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { env } from '@/lib/env' describe('Chat API Route', () => { const mockSelect = vi.fn() @@ -270,12 +269,21 @@ describe('Chat API Route', () => { }), })) + // Mock the env module to ensure NODE_ENV is development + vi.doMock('@/lib/env', () => ({ + env: { + NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + }, + })) + // Mock environment variables vi.stubGlobal('process', { ...process, env: { - ...env, + ...process.env, NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', }, }) From cbb59ba2ae9c609bc19b40bb6cb472a145d7fe81 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Jun 2025 14:47:39 -0700 Subject: [PATCH 05/10] added permission to read private channels that bot was invited to --- .../sim/app/api/tools/slack/channels/route.ts | 25 ++++++++++++++++--- .../components/oauth-required-modal.tsx | 1 + apps/sim/blocks/blocks/slack.ts | 1 + apps/sim/lib/auth.ts | 1 + apps/sim/tools/slack/message.ts | 8 +++++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 47daacd52a7..b4c10575c96 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -57,7 +57,12 @@ export async function POST(request: Request) { } // Fetch channels from Slack API - const response = await fetch('https://slack.com/api/conversations.list', { + const url = new URL('https://slack.com/api/conversations.list') + url.searchParams.append('types', 'public_channel,private_channel') + url.searchParams.append('exclude_archived', 'true') + url.searchParams.append('limit', '200') + + const response = await fetch(url.toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, @@ -85,14 +90,28 @@ export async function POST(request: Request) { // Filter to channels the bot can access and format the response const channels = data.channels - .filter((channel: SlackChannel) => !channel.is_archived && channel.is_member) + .filter((channel: SlackChannel) => { + const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private) + + if (!canAccess) { + logger.debug( + `Filtering out channel: ${channel.name} (archived: ${channel.is_archived}, private: ${channel.is_private}, member: ${channel.is_member})` + ) + } + + return canAccess + }) .map((channel: SlackChannel) => ({ id: channel.id, name: channel.name, isPrivate: channel.is_private, })) - logger.info(`Successfully fetched ${channels.length} Slack channels`) + logger.info(`Successfully fetched ${channels.length} Slack channels`, { + total: data.channels?.length || 0, + private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length, + public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length, + }) return NextResponse.json({ channels }) } catch (error) { logger.error('Error processing Slack channels request:', error) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index de67a3487dc..118825a5ece 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -109,6 +109,7 @@ const SCOPE_DESCRIPTIONS: Record = { read: 'Read access to your Linear workspace', write: 'Write access to your Linear workspace', 'channels:read': 'Read your Slack channels', + 'groups:read': 'Read your Slack private channels', 'chat:write': 'Write to your Slack channels', 'chat:write.public': 'Write to your Slack channels', 'users:read': 'Read your Slack users', diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index c57c928d157..6fa7fcd3cac 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -43,6 +43,7 @@ export const SlackBlock: BlockConfig = { serviceId: 'slack', requiredScopes: [ 'channels:read', + 'groups:read', 'chat:write', 'chat:write.public', 'users:read', diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 222ef9395b0..99cbc1b3178 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -877,6 +877,7 @@ export const auth = betterAuth({ scopes: [ // Bot token scopes only - app acts as a bot user 'channels:read', + 'groups:read', 'chat:write', 'chat:write.public', 'files:read', diff --git a/apps/sim/tools/slack/message.ts b/apps/sim/tools/slack/message.ts index 62044cbe62f..34c2ffd588d 100644 --- a/apps/sim/tools/slack/message.ts +++ b/apps/sim/tools/slack/message.ts @@ -11,7 +11,13 @@ export const slackMessageTool: ToolConfig Date: Sun, 1 Jun 2025 16:44:25 -0700 Subject: [PATCH 06/10] acknowledge PR comments, added additional typing & fallbacks --- apps/sim/app/api/chat/route.ts | 8 +- .../sim/app/api/tools/slack/channels/route.ts | 98 ++++++++++++------- .../components/slack-channel-selector.tsx | 11 +-- .../components/oauth-required-modal.tsx | 4 +- .../components/tool-input/tool-input.tsx | 2 +- apps/sim/blocks/blocks/slack.ts | 4 +- apps/sim/lib/auth-client.ts | 2 - apps/sim/lib/urls/utils.ts | 2 +- apps/sim/middleware.ts | 9 +- apps/sim/next.config.ts | 14 ++- apps/sim/tools/slack/types.ts | 2 +- 11 files changed, 96 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 662e402e673..96ed66fcca2 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -170,14 +170,18 @@ export async function POST(request: NextRequest) { // Return successful response with chat URL // Check if we're in development or production const isDevelopment = env.NODE_ENV === 'development' - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' let chatUrl: string if (isDevelopment) { try { const url = new URL(baseUrl) chatUrl = `${url.protocol}//${subdomain}.${url.host}` - } catch { + } catch (error) { + logger.warn('Failed to parse baseUrl, falling back to localhost:', { + baseUrl, + error: error instanceof Error ? error.message : 'Unknown error', + }) chatUrl = `http://${subdomain}.localhost:3000` } } else { diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index b4c10575c96..dcdf8cc1939 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -27,14 +27,13 @@ export async function POST(request: Request) { } let accessToken: string + let isBotToken = false - // Check if the credential is a bot token (starts with 'xoxb-') if (credential.startsWith('xoxb-')) { - // Direct bot token accessToken = credential + isBotToken = true logger.info('Using direct bot token for Slack API') } else { - // OAuth credential - need to resolve it const userId = session?.user?.id || '' if (!userId) { logger.error('No user ID found in session') @@ -56,40 +55,37 @@ export async function POST(request: Request) { logger.info('Using OAuth token for Slack API') } - // Fetch channels from Slack API - const url = new URL('https://slack.com/api/conversations.list') - url.searchParams.append('types', 'public_channel,private_channel') - url.searchParams.append('exclude_archived', 'true') - url.searchParams.append('limit', '200') - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - logger.error('Slack API error:', { - status: response.status, - statusText: response.statusText, - }) - return NextResponse.json( - { error: `Slack API error: ${response.status} ${response.statusText}` }, - { status: response.status } - ) - } - - const data = await response.json() - - if (!data.ok) { - logger.error('Slack API returned error:', data.error) - return NextResponse.json({ error: data.error || 'Failed to fetch channels' }, { status: 400 }) + let data + try { + data = await fetchSlackChannels(accessToken, true) + logger.info('Successfully fetched channels including private channels') + } catch (error) { + if (isBotToken) { + logger.warn( + 'Failed to fetch private channels with bot token, falling back to public channels only:', + (error as Error).message + ) + try { + data = await fetchSlackChannels(accessToken, false) + logger.info('Successfully fetched public channels only') + } catch (fallbackError) { + logger.error('Failed to fetch channels even with public-only fallback:', fallbackError) + return NextResponse.json( + { error: `Slack API error: ${(fallbackError as Error).message}` }, + { status: 400 } + ) + } + } else { + logger.error('Slack API error with OAuth token:', error) + return NextResponse.json( + { error: `Slack API error: ${(error as Error).message}` }, + { status: 400 } + ) + } } // Filter to channels the bot can access and format the response - const channels = data.channels + const channels = (data.channels || []) .filter((channel: SlackChannel) => { const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private) @@ -111,6 +107,7 @@ export async function POST(request: Request) { total: data.channels?.length || 0, private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length, public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length, + tokenType: isBotToken ? 'bot_token' : 'oauth', }) return NextResponse.json({ channels }) } catch (error) { @@ -121,3 +118,36 @@ export async function POST(request: Request) { ) } } + +async function fetchSlackChannels(accessToken: string, includePrivate = true) { + const url = new URL('https://slack.com/api/conversations.list') + + if (includePrivate) { + url.searchParams.append('types', 'public_channel,private_channel') + } else { + url.searchParams.append('types', 'public_channel') + } + + url.searchParams.append('exclude_archived', 'true') + 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 channels') + } + + return data +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx index ed52d577783..437a2aa60b0 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx @@ -24,7 +24,6 @@ interface SlackChannelSelectorProps { credential: string label?: string disabled?: boolean - showPreview?: boolean } export function SlackChannelSelector({ @@ -66,12 +65,6 @@ export function SlackChannelSelector({ } else { setChannels(data.channels) setInitialFetchDone(true) - - // Find selected channel info if we have a value - if (value) { - const channelInfo = data.channels.find((c: SlackChannelInfo) => c.id === value) - setSelectedChannel(channelInfo || null) - } } } catch (err) { if ((err as Error).name === 'AbortError') return @@ -80,7 +73,7 @@ export function SlackChannelSelector({ } finally { setLoading(false) } - }, [credential, value]) + }, [credential]) // Handle dropdown open/close - fetch channels when opening const handleOpenChange = (isOpen: boolean) => { @@ -122,7 +115,7 @@ export function SlackChannelSelector({ } const formatChannelName = (channel: SlackChannelInfo) => { - return channel.isPrivate ? channel.name : `${channel.name}` + return channel.name } return ( diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 118825a5ece..8c06924f53e 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -110,8 +110,8 @@ const SCOPE_DESCRIPTIONS: Record = { write: 'Write access to your Linear workspace', 'channels:read': 'Read your Slack channels', 'groups:read': 'Read your Slack private channels', - 'chat:write': 'Write to your Slack channels', - 'chat:write.public': 'Write to your Slack channels', + 'chat:write': 'Write to your invited Slack channels', + 'chat:write.public': 'Write to your public Slack channels', 'users:read': 'Read your Slack users', 'search:read': 'Read your Slack search', 'files:read': 'Read your Slack files', diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 4626184f5de..929e740e8ae 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -869,6 +869,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { 'space-y-3 p-3 transition-all duration-200', tool.isExpanded ? 'block' : 'hidden' )} + aria-hidden={!tool.isExpanded} onClick={(e) => { if (e.target === e.currentTarget) { toggleToolExpansion(toolIndex) @@ -969,7 +970,6 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { placeholder: channelSelectorConfig.placeholder || param.description, }} - disabled={false} credential={credentialForChannelSelector} onChannelSelect={(channelId) => { handleParamChange(toolIndex, param.id, channelId) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 6fa7fcd3cac..4fb5a338f56 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -134,8 +134,8 @@ export const SlackBlock: BlockConfig = { authMethod: { type: 'string', required: true }, credential: { type: 'string', required: false }, botToken: { type: 'string', required: false }, - channel: { type: 'string', required: false }, - text: { type: 'string', required: false }, + channel: { type: 'string', required: true }, + text: { type: 'string', required: true }, }, outputs: { response: { diff --git a/apps/sim/lib/auth-client.ts b/apps/sim/lib/auth-client.ts index 854941ac74f..aa1db292857 100644 --- a/apps/sim/lib/auth-client.ts +++ b/apps/sim/lib/auth-client.ts @@ -24,8 +24,6 @@ export function getBaseURL() { baseURL = clientEnv.NEXT_PUBLIC_APP_URL || clientEnv.BETTER_AUTH_URL || 'http://localhost:3000' } - console.log('baseURL', baseURL) - return baseURL } diff --git a/apps/sim/lib/urls/utils.ts b/apps/sim/lib/urls/utils.ts index 9c727bcd699..92a89d7a6d3 100644 --- a/apps/sim/lib/urls/utils.ts +++ b/apps/sim/lib/urls/utils.ts @@ -18,7 +18,7 @@ export function getBaseUrl(): string { return `${protocol}${baseUrl}` } - return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + return 'http://localhost:3000' } /** diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 5fe1dadeaf3..a0732886217 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -31,7 +31,7 @@ export async function middleware(request: NextRequest) { let subdomain: string | null = null try { - const baseDomainUrl = new URL(`http://${BASE_DOMAIN}`) + const baseDomainUrl = new URL(`${request.nextUrl.protocol}//${BASE_DOMAIN}`) const baseDomainHost = baseDomainUrl.hostname const baseDomainPort = baseDomainUrl.port @@ -43,7 +43,12 @@ export async function middleware(request: NextRequest) { const hasPort = lastPart.includes(':') const port = hasPort ? lastPart.split(':')[1] : null - if (!baseDomainPort || !hasPort || port === baseDomainPort) { + if (!baseDomainPort && !hasPort) { + // Both have no port - valid + isCustomDomain = true + subdomain = hostnameParts[0] + } else if (baseDomainPort && hasPort && port === baseDomainPort) { + // Both have matching ports - valid isCustomDomain = true subdomain = hostnameParts[0] } diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 2bf062cc994..ec1968ce8c5 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -27,12 +27,18 @@ const nextConfig: NextConfig = { }, ...(env.NODE_ENV === 'development' && { allowedDevOrigins: [ - ...(process.env.NEXT_PUBLIC_APP_URL ? [new URL(process.env.NEXT_PUBLIC_APP_URL).host] : []), + ...(process.env.NEXT_PUBLIC_APP_URL + ? (() => { + try { + return [new URL(process.env.NEXT_PUBLIC_APP_URL).host] + } catch { + return [] + } + })() + : []), 'localhost:3000', 'localhost:3001', ], - }), - ...(env.NODE_ENV === 'development' && { outputFileTracingRoot: path.join(__dirname, '../../'), }), webpack: (config, { isServer, dev }) => { @@ -75,7 +81,7 @@ const nextConfig: NextConfig = { { key: 'Access-Control-Allow-Credentials', value: 'true' }, { key: 'Access-Control-Allow-Origin', - value: process.env.NEXT_PUBLIC_APP_URL || 'https://localhost:3001', + value: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001', }, { key: 'Access-Control-Allow-Methods', diff --git a/apps/sim/tools/slack/types.ts b/apps/sim/tools/slack/types.ts index 767cd06d22a..27649697fde 100644 --- a/apps/sim/tools/slack/types.ts +++ b/apps/sim/tools/slack/types.ts @@ -1,7 +1,7 @@ import type { ToolResponse } from '../types' export interface SlackBaseParams { - authMethod: string + authMethod: 'oauth' | 'bot_token' accessToken: string botToken: string } From db82ca6c872ca57a8497d7fb6af1ed1a4277b72a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Jun 2025 19:35:59 -0700 Subject: [PATCH 07/10] fixed build error --- .../components/channel-selector/channel-selector-input.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx index d480073e8c0..8b57f287bbf 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -73,7 +73,6 @@ export function ChannelSelectorInput({ credential={credential} label={subBlock.placeholder || 'Select Slack channel'} disabled={disabled || !credential} - showPreview={true} /> From 5c40fef61fecf94cd81865440367f9e56a5f4f6a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Jun 2025 20:05:37 -0700 Subject: [PATCH 08/10] remove fallback logic for password fields --- apps/sim/app/api/chat/route.test.ts | 2 -- .../components/tool-input/tool-input.tsx | 18 +----------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 54cb8337479..f05d521bece 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -269,7 +269,6 @@ describe('Chat API Route', () => { }), })) - // Mock the env module to ensure NODE_ENV is development vi.doMock('@/lib/env', () => ({ env: { NODE_ENV: 'development', @@ -277,7 +276,6 @@ describe('Chat API Route', () => { }, })) - // Mock environment variables vi.stubGlobal('process', { ...process, env: { diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 929e740e8ae..78bce6d2f87 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -259,7 +259,6 @@ const getChannelSelectorConfig = (blockType: string, paramId: string) => { // Helper function to check if a parameter should be treated as a password field const shouldBePasswordField = (blockType: string, paramId: string): boolean => { - // Check if the block configuration explicitly sets password: true for this parameter const block = getAllBlocks().find((block) => block.type === blockType) if (block) { const subBlock = block.subBlocks.find((sb) => sb.id === paramId) @@ -268,22 +267,7 @@ const shouldBePasswordField = (blockType: string, paramId: string): boolean => { } } - // Fallback: check for common password/API key patterns - const normalizedId = paramId.toLowerCase().replace(/\s+/g, '') - const passwordPatterns = [ - 'apikey', - 'api_key', - 'secretkey', - 'secret_key', - 'token', - 'bottoken', - 'accesstoken', - 'authtoken', - 'password', - 'secret', - ] - - return passwordPatterns.some((pattern) => normalizedId.includes(pattern)) + return false } export function ToolInput({ blockId, subBlockId }: ToolInputProps) { From 7f27de1599d1c0c88810145ee960f4ae2d7f8aca Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Jun 2025 20:19:22 -0700 Subject: [PATCH 09/10] reverted changes to middleware --- apps/sim/middleware.ts | 50 +++++------------------------------------- 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index a0732886217..1a7f9ea498e 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -27,54 +27,16 @@ export async function middleware(request: NextRequest) { const url = request.nextUrl const hostname = request.headers.get('host') || '' - let isCustomDomain = false - let subdomain: string | null = null - - try { - const baseDomainUrl = new URL(`${request.nextUrl.protocol}//${BASE_DOMAIN}`) - const baseDomainHost = baseDomainUrl.hostname - const baseDomainPort = baseDomainUrl.port - - if (hostname !== BASE_DOMAIN && !hostname.startsWith('www.')) { - if (isDevelopment && baseDomainHost === 'localhost') { - const hostnameParts = hostname.split('.') - if (hostnameParts.length >= 2 && hostnameParts[1] === 'localhost') { - const lastPart = hostnameParts[hostnameParts.length - 1] - const hasPort = lastPart.includes(':') - const port = hasPort ? lastPart.split(':')[1] : null - - if (!baseDomainPort && !hasPort) { - // Both have no port - valid - isCustomDomain = true - subdomain = hostnameParts[0] - } else if (baseDomainPort && hasPort && port === baseDomainPort) { - // Both have matching ports - valid - isCustomDomain = true - subdomain = hostnameParts[0] - } - } - } else if (!isDevelopment && hostname.endsWith('.simstudio.ai')) { - const hostnameParts = hostname.split('.') - if (hostnameParts.length >= 3) { - isCustomDomain = true - subdomain = hostnameParts[0] - } - } - } - } catch (error) { - logger.warn('Error parsing base domain for subdomain detection:', error) - isCustomDomain = - hostname !== BASE_DOMAIN && - !hostname.startsWith('www.') && - hostname.includes(isDevelopment ? 'localhost' : 'simstudio.ai') - subdomain = isCustomDomain ? hostname.split('.')[0] : null - } + // Extract subdomain + const isCustomDomain = + hostname !== BASE_DOMAIN && + !hostname.startsWith('www.') && + hostname.includes(isDevelopment ? 'localhost' : 'simstudio.ai') + const subdomain = isCustomDomain ? hostname.split('.')[0] : null // Handle chat subdomains if (subdomain && isCustomDomain) { - // Special case for API requests from the subdomain if (url.pathname.startsWith('/api/chat/')) { - // Already an API request, let it go through return NextResponse.next() } From f16c4ae356b3febcd8325aa89c47df8318414a59 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 1 Jun 2025 20:25:49 -0700 Subject: [PATCH 10/10] cleanup --- apps/sim/lib/auth-client.ts | 1 - apps/sim/lib/oauth.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/sim/lib/auth-client.ts b/apps/sim/lib/auth-client.ts index aa1db292857..77e3c273eb3 100644 --- a/apps/sim/lib/auth-client.ts +++ b/apps/sim/lib/auth-client.ts @@ -20,7 +20,6 @@ export function getBaseURL() { } else if (clientEnv.VERCEL_ENV === 'production') { baseURL = clientEnv.BETTER_AUTH_URL || clientEnv.NEXT_PUBLIC_APP_URL } else if (clientEnv.NODE_ENV === 'development') { - // For development, prioritize NEXT_PUBLIC_APP_URL for client-side requests baseURL = clientEnv.NEXT_PUBLIC_APP_URL || clientEnv.BETTER_AUTH_URL || 'http://localhost:3000' } diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index 4cc8a7001af..a501984ca6f 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -373,7 +373,7 @@ export const OAUTH_PROVIDERS: Record = { slack: { id: 'slack', name: 'Slack', - description: 'Send messages, search conversations, and manage Slack workspaces.', + description: 'Send messages using a Slack bot.', providerId: 'slack', icon: (props) => SlackIcon(props), baseProviderIcon: (props) => SlackIcon(props),