mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
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:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
153
apps/sim/app/api/tools/slack/channels/route.ts
Normal file
153
apps/sim/app/api/tools/slack/channels/route.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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'`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user