Skip to content

Commit de2ce6f

Browse files
authored
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
1 parent 2e77d46 commit de2ce6f

File tree

22 files changed

+907
-94
lines changed

22 files changed

+907
-94
lines changed

apps/sim/app/(landing)/contributors/page.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ export default function ContributorsPage() {
433433
<ResponsiveContainer width='100%' height={300} className='sm:!h-[400px]'>
434434
<BarChart
435435
data={filteredContributors?.slice(0, showAllContributors ? undefined : 10)}
436-
margin={{ top: 10, right: 5, bottom: 50, left: 5 }}
436+
margin={{ top: 10, right: 5, bottom: 45, left: 5 }}
437437
className='sm:!mx-2.5 sm:!mb-2.5'
438438
>
439439
<XAxis
@@ -461,21 +461,11 @@ export default function ContributorsPage() {
461461
</AvatarFallback>
462462
</Avatar>
463463
</foreignObject>
464-
<text
465-
x='0'
466-
y='40'
467-
textAnchor='middle'
468-
className='fill-neutral-400 text-[10px] sm:text-xs'
469-
>
470-
{payload.value.length > 6
471-
? `${payload.value.slice(0, 6)}...`
472-
: payload.value}
473-
</text>
474464
</g>
475465
)
476466
}}
477-
height={60}
478-
className='sm:!h-[80px] text-neutral-400'
467+
height={50}
468+
className='sm:!h-[60px] text-neutral-400'
479469
/>
480470
<YAxis
481471
stroke='currentColor'

apps/sim/app/api/chat/route.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { NextRequest } from 'next/server'
55
* @vitest-environment node
66
*/
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8-
import { env } from '@/lib/env'
98

109
describe('Chat API Route', () => {
1110
const mockSelect = vi.fn()
@@ -270,12 +269,19 @@ describe('Chat API Route', () => {
270269
}),
271270
}))
272271

273-
// Mock environment variables
272+
vi.doMock('@/lib/env', () => ({
273+
env: {
274+
NODE_ENV: 'development',
275+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
276+
},
277+
}))
278+
274279
vi.stubGlobal('process', {
275280
...process,
276281
env: {
277-
...env,
282+
...process.env,
278283
NODE_ENV: 'development',
284+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
279285
},
280286
})
281287

apps/sim/app/api/chat/route.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,23 @@ export async function POST(request: NextRequest) {
170170
// Return successful response with chat URL
171171
// Check if we're in development or production
172172
const isDevelopment = env.NODE_ENV === 'development'
173-
const chatUrl = isDevelopment
174-
? `http://${subdomain}.localhost:3000`
175-
: `https://${subdomain}.simstudio.ai`
173+
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
174+
175+
let chatUrl: string
176+
if (isDevelopment) {
177+
try {
178+
const url = new URL(baseUrl)
179+
chatUrl = `${url.protocol}//${subdomain}.${url.host}`
180+
} catch (error) {
181+
logger.warn('Failed to parse baseUrl, falling back to localhost:', {
182+
baseUrl,
183+
error: error instanceof Error ? error.message : 'Unknown error',
184+
})
185+
chatUrl = `http://${subdomain}.localhost:3000`
186+
}
187+
} else {
188+
chatUrl = `https://${subdomain}.simstudio.ai`
189+
}
176190

177191
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)
178192

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { NextResponse } from 'next/server'
2+
import { getSession } from '@/lib/auth'
3+
import { createLogger } from '@/lib/logs/console-logger'
4+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
5+
6+
export const dynamic = 'force-dynamic'
7+
8+
const logger = createLogger('SlackChannelsAPI')
9+
10+
interface SlackChannel {
11+
id: string
12+
name: string
13+
is_private: boolean
14+
is_archived: boolean
15+
is_member: boolean
16+
}
17+
18+
export async function POST(request: Request) {
19+
try {
20+
const session = await getSession()
21+
const body = await request.json()
22+
const { credential, workflowId } = body
23+
24+
if (!credential) {
25+
logger.error('Missing credential in request')
26+
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
27+
}
28+
29+
let accessToken: string
30+
let isBotToken = false
31+
32+
if (credential.startsWith('xoxb-')) {
33+
accessToken = credential
34+
isBotToken = true
35+
logger.info('Using direct bot token for Slack API')
36+
} else {
37+
const userId = session?.user?.id || ''
38+
if (!userId) {
39+
logger.error('No user ID found in session')
40+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
41+
}
42+
43+
const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
44+
if (!resolvedToken) {
45+
logger.error('Failed to get access token', { credentialId: credential, userId })
46+
return NextResponse.json(
47+
{
48+
error: 'Could not retrieve access token',
49+
authRequired: true,
50+
},
51+
{ status: 401 }
52+
)
53+
}
54+
accessToken = resolvedToken
55+
logger.info('Using OAuth token for Slack API')
56+
}
57+
58+
let data
59+
try {
60+
data = await fetchSlackChannels(accessToken, true)
61+
logger.info('Successfully fetched channels including private channels')
62+
} catch (error) {
63+
if (isBotToken) {
64+
logger.warn(
65+
'Failed to fetch private channels with bot token, falling back to public channels only:',
66+
(error as Error).message
67+
)
68+
try {
69+
data = await fetchSlackChannels(accessToken, false)
70+
logger.info('Successfully fetched public channels only')
71+
} catch (fallbackError) {
72+
logger.error('Failed to fetch channels even with public-only fallback:', fallbackError)
73+
return NextResponse.json(
74+
{ error: `Slack API error: ${(fallbackError as Error).message}` },
75+
{ status: 400 }
76+
)
77+
}
78+
} else {
79+
logger.error('Slack API error with OAuth token:', error)
80+
return NextResponse.json(
81+
{ error: `Slack API error: ${(error as Error).message}` },
82+
{ status: 400 }
83+
)
84+
}
85+
}
86+
87+
// Filter to channels the bot can access and format the response
88+
const channels = (data.channels || [])
89+
.filter((channel: SlackChannel) => {
90+
const canAccess = !channel.is_archived && (channel.is_member || !channel.is_private)
91+
92+
if (!canAccess) {
93+
logger.debug(
94+
`Filtering out channel: ${channel.name} (archived: ${channel.is_archived}, private: ${channel.is_private}, member: ${channel.is_member})`
95+
)
96+
}
97+
98+
return canAccess
99+
})
100+
.map((channel: SlackChannel) => ({
101+
id: channel.id,
102+
name: channel.name,
103+
isPrivate: channel.is_private,
104+
}))
105+
106+
logger.info(`Successfully fetched ${channels.length} Slack channels`, {
107+
total: data.channels?.length || 0,
108+
private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length,
109+
public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length,
110+
tokenType: isBotToken ? 'bot_token' : 'oauth',
111+
})
112+
return NextResponse.json({ channels })
113+
} catch (error) {
114+
logger.error('Error processing Slack channels request:', error)
115+
return NextResponse.json(
116+
{ error: 'Failed to retrieve Slack channels', details: (error as Error).message },
117+
{ status: 500 }
118+
)
119+
}
120+
}
121+
122+
async function fetchSlackChannels(accessToken: string, includePrivate = true) {
123+
const url = new URL('https://slack.com/api/conversations.list')
124+
125+
if (includePrivate) {
126+
url.searchParams.append('types', 'public_channel,private_channel')
127+
} else {
128+
url.searchParams.append('types', 'public_channel')
129+
}
130+
131+
url.searchParams.append('exclude_archived', 'true')
132+
url.searchParams.append('limit', '200')
133+
134+
const response = await fetch(url.toString(), {
135+
method: 'GET',
136+
headers: {
137+
Authorization: `Bearer ${accessToken}`,
138+
'Content-Type': 'application/json',
139+
},
140+
})
141+
142+
if (!response.ok) {
143+
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
144+
}
145+
146+
const data = await response.json()
147+
148+
if (!data.ok) {
149+
throw new Error(data.error || 'Failed to fetch channels')
150+
}
151+
152+
return data
153+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
5+
import type { SubBlockConfig } from '@/blocks/types'
6+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
7+
import { type SlackChannelInfo, SlackChannelSelector } from './components/slack-channel-selector'
8+
9+
interface ChannelSelectorInputProps {
10+
blockId: string
11+
subBlock: SubBlockConfig
12+
disabled?: boolean
13+
onChannelSelect?: (channelId: string) => void
14+
credential?: string // Optional credential override
15+
}
16+
17+
export function ChannelSelectorInput({
18+
blockId,
19+
subBlock,
20+
disabled = false,
21+
onChannelSelect,
22+
credential: providedCredential,
23+
}: ChannelSelectorInputProps) {
24+
const { getValue, setValue } = useSubBlockStore()
25+
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
26+
const [_channelInfo, setChannelInfo] = useState<SlackChannelInfo | null>(null)
27+
28+
// Get provider-specific values
29+
const provider = subBlock.provider || 'slack'
30+
const isSlack = provider === 'slack'
31+
32+
// Get the credential for the provider - use provided credential or fall back to store
33+
const authMethod = getValue(blockId, 'authMethod') as string
34+
const botToken = getValue(blockId, 'botToken') as string
35+
36+
let credential: string
37+
if (providedCredential) {
38+
credential = providedCredential
39+
} else if (authMethod === 'bot_token' && botToken) {
40+
credential = botToken
41+
} else {
42+
credential = (getValue(blockId, 'credential') as string) || ''
43+
}
44+
45+
// Get the current value from the store
46+
useEffect(() => {
47+
const value = getValue(blockId, subBlock.id)
48+
if (value && typeof value === 'string') {
49+
setSelectedChannelId(value)
50+
}
51+
}, [blockId, subBlock.id, getValue])
52+
53+
// Handle channel selection
54+
const handleChannelChange = (channelId: string, info?: SlackChannelInfo) => {
55+
setSelectedChannelId(channelId)
56+
setChannelInfo(info || null)
57+
setValue(blockId, subBlock.id, channelId)
58+
onChannelSelect?.(channelId)
59+
}
60+
61+
// Render Slack channel selector
62+
if (isSlack) {
63+
return (
64+
<TooltipProvider>
65+
<Tooltip>
66+
<TooltipTrigger asChild>
67+
<div className='w-full'>
68+
<SlackChannelSelector
69+
value={selectedChannelId}
70+
onChange={(channelId: string, channelInfo?: SlackChannelInfo) => {
71+
handleChannelChange(channelId, channelInfo)
72+
}}
73+
credential={credential}
74+
label={subBlock.placeholder || 'Select Slack channel'}
75+
disabled={disabled || !credential}
76+
/>
77+
</div>
78+
</TooltipTrigger>
79+
{!credential && (
80+
<TooltipContent side='top'>
81+
<p>Please select a Slack account or enter a bot token first</p>
82+
</TooltipContent>
83+
)}
84+
</Tooltip>
85+
</TooltipProvider>
86+
)
87+
}
88+
89+
// Default fallback for unsupported providers
90+
return (
91+
<TooltipProvider>
92+
<Tooltip>
93+
<TooltipTrigger asChild>
94+
<div className='w-full rounded border border-dashed p-4 text-center text-muted-foreground text-sm'>
95+
Channel selector not supported for provider: {provider}
96+
</div>
97+
</TooltipTrigger>
98+
<TooltipContent side='top'>
99+
<p>This channel selector is not yet implemented for {provider}</p>
100+
</TooltipContent>
101+
</Tooltip>
102+
</TooltipProvider>
103+
)
104+
}

0 commit comments

Comments
 (0)