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' /> { const mockSelect = vi.fn() @@ -270,12 +269,19 @@ describe('Chat API Route', () => { }), })) - // Mock environment variables + vi.doMock('@/lib/env', () => ({ + env: { + NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + }, + })) + vi.stubGlobal('process', { ...process, env: { - ...env, + ...process.env, NODE_ENV: 'development', + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', }, }) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index c63e0285775..96ed66fcca2 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -170,9 +170,23 @@ 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 = 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 (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 { + 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..dcdf8cc1939 --- /dev/null +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -0,0 +1,153 @@ +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 + let isBotToken = false + + if (credential.startsWith('xoxb-')) { + accessToken = credential + isBotToken = true + logger.info('Using direct bot token for Slack API') + } else { + 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') + } + + 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 || []) + .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`, { + 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) { + logger.error('Error processing Slack channels request:', error) + return NextResponse.json( + { error: 'Failed to retrieve Slack channels', details: (error as Error).message }, + { status: 500 } + ) + } +} + +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/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..8b57f287bbf --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/channel-selector/channel-selector-input.tsx @@ -0,0 +1,104 @@ +'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} + /> +
+
+ {!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..437a2aa60b0 --- /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,206 @@ +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 +} + +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) + } + } catch (err) { + if ((err as Error).name === 'AbortError') return + setError((err as Error).message) + setChannels([]) + } finally { + setLoading(false) + } + }, [credential]) + + // 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.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..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 @@ -108,6 +108,15 @@ 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', + 'groups:read': 'Read your Slack private 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', + '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..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 @@ -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,38 @@ 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 => { + const block = getAllBlocks().find((block) => block.type === blockType) + if (block) { + const subBlock = block.subBlocks.find((sb) => sb.id === paramId) + if (subBlock?.password) { + return true + } + } + + return false +} + export function ToolInput({ blockId, subBlockId }: ToolInputProps) { const [value, setValue] = useSubBlockValue(blockId, subBlockId) const [open, setOpen] = useState(false) @@ -817,9 +847,13 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { - {tool.isExpanded && !isCustomTool && isExpandable && ( + {!isCustomTool && isExpandable && (
{ if (e.target === e.currentTarget) { toggleToolExpansion(toolIndex) @@ -873,34 +907,80 @@ 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 + + // 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..4fb5a338f56 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -2,46 +2,138 @@ 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', + 'groups: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 }, + operation: { type: 'string', required: true }, + authMethod: { type: 'string', required: true }, + credential: { type: 'string', required: false }, + botToken: { type: 'string', required: false }, channel: { type: 'string', required: true }, text: { type: 'string', required: true }, }, 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..77e3c273eb3 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,9 +18,9 @@ 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 + baseURL = clientEnv.NEXT_PUBLIC_APP_URL || clientEnv.BETTER_AUTH_URL || 'http://localhost:3000' } return baseURL diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index f837d41c010..99cbc1b3178 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,71 @@ 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', + 'groups: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..a501984ca6f 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 using a Slack bot.', + 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..92a89d7a6d3 100644 --- a/apps/sim/lib/urls/utils.ts +++ b/apps/sim/lib/urls/utils.ts @@ -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..1a7f9ea498e 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -24,9 +24,6 @@ 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') || '' @@ -39,9 +36,7 @@ export async function middleware(request: NextRequest) { // 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() } diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 415cfd35b4a..ec1968ce8c5 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -26,6 +26,19 @@ const nextConfig: NextConfig = { optimizeCss: true, }, ...(env.NODE_ENV === 'development' && { + allowedDevOrigins: [ + ...(process.env.NEXT_PUBLIC_APP_URL + ? (() => { + try { + return [new URL(process.env.NEXT_PUBLIC_APP_URL).host] + } catch { + return [] + } + })() + : []), + 'localhost:3000', + 'localhost:3001', + ], outputFileTracingRoot: path.join(__dirname, '../../'), }), webpack: (config, { isServer, dev }) => { @@ -68,7 +81,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 || 'http://localhost:3001', }, { key: 'Access-Control-Allow-Methods', @@ -138,7 +151,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..34c2ffd588d 100644 --- a/apps/sim/tools/slack/message.ts +++ b/apps/sim/tools/slack/message.ts @@ -5,15 +5,32 @@ 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..27649697fde 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: 'oauth' | 'bot_token' + accessToken: string + botToken: string +} + +export interface SlackMessageParams extends SlackBaseParams { channel: string text: string + thread_ts?: string } export interface SlackMessageResponse extends ToolResponse {