feat(slack): added slack oauth for sim bot & maintained old custom bot, fixed markdown rendering (#445)

* fix formatting of contributors chart

* added slack oauth, removed hardcoded localhosts and use NEXT_PUBLIC_APP_URL instead

* remove conditional rendering of subblocks for tools in an agent blcok

* updated tests

* added permission to read private channels that bot was invited to

* acknowledge PR comments, added additional typing & fallbacks

* fixed build error

* remove fallback logic for password fields

* reverted changes to middleware

* cleanup
This commit is contained in:
Waleed Latif
2025-06-01 20:34:19 -07:00
committed by GitHub
parent 2e77d4625a
commit de2ce6fcb7
22 changed files with 908 additions and 95 deletions

View File

@@ -433,7 +433,7 @@ export default function ContributorsPage() {
<ResponsiveContainer width='100%' height={300} className='sm:!h-[400px]'>
<BarChart
data={filteredContributors?.slice(0, showAllContributors ? undefined : 10)}
margin={{ top: 10, right: 5, bottom: 50, left: 5 }}
margin={{ top: 10, right: 5, bottom: 45, left: 5 }}
className='sm:!mx-2.5 sm:!mb-2.5'
>
<XAxis
@@ -461,21 +461,11 @@ export default function ContributorsPage() {
</AvatarFallback>
</Avatar>
</foreignObject>
<text
x='0'
y='40'
textAnchor='middle'
className='fill-neutral-400 text-[10px] sm:text-xs'
>
{payload.value.length > 6
? `${payload.value.slice(0, 6)}...`
: payload.value}
</text>
</g>
)
}}
height={60}
className='sm:!h-[80px] text-neutral-400'
height={50}
className='sm:!h-[60px] text-neutral-400'
/>
<YAxis
stroke='currentColor'

View File

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

View File

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

View File

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

View File

@@ -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<string>('')
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<SlackChannelSelector
value={selectedChannelId}
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
handleChannelChange(channelId, channelInfo)
}}
credential={credential}
label={subBlock.placeholder || 'Select Slack channel'}
disabled={disabled || !credential}
/>
</div>
</TooltipTrigger>
{!credential && (
<TooltipContent side='top'>
<p>Please select a Slack account or enter a bot token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Default fallback for unsupported providers
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
Channel selector not supported for provider: {provider}
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<p>This channel selector is not yet implemented for {provider}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -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<SlackChannelInfo[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedChannel, setSelectedChannel] = useState<SlackChannelInfo | null>(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 ? <Lock className='h-1.5 w-1.5' /> : <Hash className='h-1.5 w-1.5' />
}
const formatChannelName = (channel: SlackChannelInfo) => {
return channel.name
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled || !credential}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{selectedChannel ? (
<>
{getChannelIcon(selectedChannel)}
<span className='truncate font-normal'>{formatChannelName(selectedChannel)}</span>
</>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[250px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search channels...' />
<CommandList>
<CommandEmpty>
{loading ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading channels...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !credential ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>Missing credentials</p>
<p className='text-muted-foreground text-xs'>
Please configure Slack credentials.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No channels found</p>
<p className='text-muted-foreground text-xs'>
No channels available for this Slack workspace.
</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Channels
</div>
{channels.map((channel) => (
<CommandItem
key={channel.id}
value={`channel-${channel.id}-${channel.name}`}
onSelect={() => handleSelectChannel(channel)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<SlackIcon className='h-4 w-4 text-[#611f69]' />
{getChannelIcon(channel)}
<span className='truncate font-normal'>{formatChannelName(channel)}</span>
{channel.isPrivate && (
<span className='ml-auto text-muted-foreground text-xs'>Private</span>
)}
</div>
{channel.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

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

View File

@@ -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) {
</div>
</div>
{tool.isExpanded && !isCustomTool && isExpandable && (
{!isCustomTool && isExpandable && (
<div
className='space-y-3 p-3'
className={cn(
'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)
@@ -873,34 +907,80 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
})()}
{/* Existing parameters */}
{requiredParams.map((param) => (
<div key={param.id} className='relative space-y-1.5'>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{formatParamId(param.id)}
{param.optionalToolInput && !param.requiredForToolCall && (
<span className='ml-1 text-muted-foreground/60 text-xs'>
(Optional)
</span>
)}
{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 (
<div key={param.id} className='relative space-y-1.5'>
<div className='flex items-center font-medium text-muted-foreground text-xs'>
{formatParamId(param.id)}
{param.optionalToolInput && !param.requiredForToolCall && (
<span className='ml-1 text-muted-foreground/60 text-xs'>
(Optional)
</span>
)}
</div>
<div className='relative'>
{useChannelSelector && channelSelectorConfig ? (
<ChannelSelectorInput
blockId={blockId}
subBlock={{
id: param.id,
type: 'channel-selector',
title: channelSelectorConfig.title || formatParamId(param.id),
provider: channelSelectorConfig.provider,
placeholder:
channelSelectorConfig.placeholder || param.description,
}}
credential={credentialForChannelSelector}
onChannelSelect={(channelId) => {
handleParamChange(toolIndex, param.id, channelId)
}}
/>
) : (
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={shouldBePasswordField(tool.type, param.id)}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) =>
handleParamChange(toolIndex, param.id, value)
}
/>
)}
</div>
</div>
<div className='relative'>
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={param.id.toLowerCase().replace(/\s+/g, '') === 'apikey'}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) => handleParamChange(toolIndex, param.id, value)}
/>
</div>
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -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 <FileSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'project-selector':
return <ProjectSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'channel-selector':
return <ChannelSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'folder-selector':
return <FolderSelectorInput blockId={blockId} subBlock={config} disabled={isConnecting} />
case 'input-format':

View File

@@ -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<SlackMessageResponse> = {
type SlackResponse = SlackMessageResponse
export const SlackBlock: BlockConfig<SlackResponse> = {
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',
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [{ label: 'Send Message', id: 'send' }],
value: () => 'send',
},
{
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',
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: 'botToken',
title: 'Bot Token',
type: 'short-input',
layout: 'full',
placeholder: 'Enter Slack channel (e.g., #general)',
placeholder: 'Enter your Slack bot token (xoxb-...)',
password: true,
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 alert message',
},
{
id: 'apiKey',
title: 'OAuth Token',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your Slack OAuth token',
password: true,
connectionDroppable: false,
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 },
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, OAuthProviderConfig> = {
},
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}`)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,15 +5,32 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
id: 'slack_message',
name: 'Slack Message',
description:
'Send messages to Slack channels or users through the Slack API. Enables direct communication and notifications with timestamp tracking and channel confirmation.',
'Send messages to Slack channels or users through the Slack API. Supports Slack mrkdwn formatting.',
version: '1.0.0',
oauth: {
required: true,
provider: 'slack',
additionalScopes: [
'channels:read',
'groups:read',
'chat:write',
'chat:write.public',
'users:read',
],
},
params: {
apiKey: {
botToken: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Your Slack API token',
required: false,
optionalToolInput: true,
description: 'Bot token for Custom Bot',
},
accessToken: {
type: 'string',
required: false,
description: 'OAuth access token or bot token for Slack API',
},
channel: {
type: 'string',
@@ -24,7 +41,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
text: {
type: 'string',
required: true,
description: 'Message text to send',
description: 'Message text to send (supports Slack mrkdwn formatting)',
},
},
@@ -33,12 +50,16 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
method: 'POST',
headers: (params: SlackMessageParams) => ({
'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) => {

View File

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