diff --git a/apps/docs/content/docs/tools/discord.mdx b/apps/docs/content/docs/tools/discord.mdx new file mode 100644 index 000000000..bbce58594 --- /dev/null +++ b/apps/docs/content/docs/tools/discord.mdx @@ -0,0 +1,142 @@ +--- +title: Discord +description: Interact with Discord +--- + +import { BlockInfoCard } from '@/components/ui/block-info-card' + + + + + + + +`} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Discord](https://discord.com) is a powerful communication platform that allows you to connect with friends, communities, and teams. It offers a range of features for team collaboration, including text channels, voice channels, and video calls. + +With a Discord account or bot, you can: + +- **Send messages**: Send messages to a specific channel +- **Get messages**: Get messages from a specific channel +- **Get server**: Get information about a specific server +- **Get user**: Get information about a specific user + +In Sim Studio, the Discord integration enables your agents to access and leverage your organization's Discord servers. Agents can retrieve information from Discord channels, search for specific users, get server information, and send messages. This allows your workflows to integrate with your Discord communities, automate notifications, and create interactive experiences. + +> **Important:** To read message content, your Discord bot needs the "Message Content Intent" enabled in the Discord Developer Portal. Without this permission, you'll still receive message metadata but the content field will appear empty. + +Discord components in Sim Studio use efficient lazy loading, only fetching data when needed to minimize API calls and prevent rate limiting. Token refreshing happens automatically in the background to maintain your connection. + +### Setting Up Your Discord Bot + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application and navigate to the "Bot" tab +3. Create a bot and copy your bot token +4. Under "Privileged Gateway Intents", enable the **Message Content Intent** to read message content +5. Invite your bot to your servers with appropriate permissions + {/* MANUAL-CONTENT-END */} + +## Usage Instructions + +Connect to Discord to send messages, manage channels, and interact with servers. Automate notifications, community management, and integrate Discord into your workflows. + +## Tools + +### `discord_send_message` + +Send a message to a Discord channel + +#### Input + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | --------------------------------------------- | +| `botToken` | string | Yes | The bot token for authentication | +| `channelId` | string | Yes | The Discord channel ID to send the message to | +| `content` | string | No | The text content of the message | +| `serverId` | string | Yes | The Discord server ID \(guild ID\) | + +#### Output + +| Parameter | Type | +| --------- | ------ | +| `message` | string | + +### `discord_get_messages` + +Retrieve messages from a Discord channel + +#### Input + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ---------------------------------------------------------------- | +| `botToken` | string | Yes | The bot token for authentication | +| `channelId` | string | Yes | The Discord channel ID to retrieve messages from | +| `limit` | number | No | Maximum number of messages to retrieve \(default: 10, max: 100\) | + +#### Output + +| Parameter | Type | +| --------- | ------ | +| `message` | string | + +### `discord_get_server` + +Retrieve information about a Discord server (guild) + +#### Input + +| Parameter | Type | Required | Description | +| ---------- | ------ | -------- | ---------------------------------- | +| `botToken` | string | Yes | The bot token for authentication | +| `serverId` | string | Yes | The Discord server ID \(guild ID\) | + +#### Output + +| Parameter | Type | +| --------- | ------ | +| `message` | string | + +### `discord_get_user` + +Retrieve information about a Discord user + +#### Input + +| Parameter | Type | Required | Description | +| ---------- | ------ | -------- | ------------------------------------ | +| `botToken` | string | Yes | Discord bot token for authentication | +| `userId` | string | Yes | The Discord user ID | + +#### Output + +| Parameter | Type | +| --------- | ------ | +| `message` | string | + +## Block Configuration + +### Input + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ----------- | +| `operation` | string | Yes | Operation | + +### Outputs + +| Output | Type | Description | +| ----------- | ------ | ----------------------- | +| `response` | object | Output from response | +| ↳ `message` | string | message of the response | +| ↳ `data` | any | data of the response | + +## Notes + +- Category: `tools` +- Type: `discord` diff --git a/apps/docs/content/docs/tools/elevenlabs.mdx b/apps/docs/content/docs/tools/elevenlabs.mdx index 5b714bb50..1d4cde59a 100644 --- a/apps/docs/content/docs/tools/elevenlabs.mdx +++ b/apps/docs/content/docs/tools/elevenlabs.mdx @@ -67,7 +67,7 @@ Convert TTS using ElevenLabs voices | Parameter | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------ | -| `text` | string | No | Text - Enter the text to convert to speech | +| `text` | string | Yes | Text - Enter the text to convert to speech | ### Outputs diff --git a/apps/docs/content/docs/tools/google_search.mdx b/apps/docs/content/docs/tools/google_search.mdx index 50dfa2792..2c631aa75 100644 --- a/apps/docs/content/docs/tools/google_search.mdx +++ b/apps/docs/content/docs/tools/google_search.mdx @@ -78,7 +78,7 @@ Search the web with the Custom Search API | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------------------------- | -| `query` | string | No | Search Query - Enter your search query | +| `query` | string | Yes | Search Query - Enter your search query | ### Outputs diff --git a/apps/docs/content/docs/tools/meta.json b/apps/docs/content/docs/tools/meta.json index ffafb325a..f331ca1a8 100644 --- a/apps/docs/content/docs/tools/meta.json +++ b/apps/docs/content/docs/tools/meta.json @@ -6,6 +6,7 @@ "browser_use", "clay", "confluence", + "discord", "dropdown", "elevenlabs", "exa", diff --git a/apps/docs/content/docs/tools/reddit.mdx b/apps/docs/content/docs/tools/reddit.mdx index 3a39c451c..cf8e4d81a 100644 --- a/apps/docs/content/docs/tools/reddit.mdx +++ b/apps/docs/content/docs/tools/reddit.mdx @@ -114,7 +114,7 @@ Fetch comments from a specific Reddit post | Parameter | Type | Required | Description | | --------- | ------ | -------- | ----------- | -| `action` | string | No | Action | +| `action` | string | Yes | Action | ### Outputs diff --git a/apps/docs/content/docs/tools/thinking.mdx b/apps/docs/content/docs/tools/thinking.mdx index 7089e647f..72933433c 100644 --- a/apps/docs/content/docs/tools/thinking.mdx +++ b/apps/docs/content/docs/tools/thinking.mdx @@ -63,7 +63,7 @@ Processes a provided thought/instruction, making it available for subsequent ste | Parameter | Type | Required | Description | | --------- | ------ | -------- | ---------------------------------------------------------------------------------- | -| `thought` | string | No | Thought Process / Instruction - Describe the step-by-step thinking process here... | +| `thought` | string | Yes | Thought Process / Instruction - Describe the step-by-step thinking process here... | ### Outputs diff --git a/apps/sim/app/api/auth/oauth/discord/channels/route.ts b/apps/sim/app/api/auth/oauth/discord/channels/route.ts new file mode 100644 index 000000000..14db8138b --- /dev/null +++ b/apps/sim/app/api/auth/oauth/discord/channels/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' + +interface DiscordChannel { + id: string + name: string + type: number + guild_id?: string +} + +const logger = createLogger('DiscordChannelsAPI') + +export async function POST(request: Request) { + try { + const { botToken, serverId, channelId } = await request.json() + + if (!botToken) { + logger.error('Missing bot token in request') + return NextResponse.json({ error: 'Bot token is required' }, { status: 400 }) + } + + if (!serverId) { + logger.error('Missing server ID in request') + return NextResponse.json({ error: 'Server ID is required' }, { status: 400 }) + } + + // If channelId is provided, we'll fetch just that specific channel + if (channelId) { + logger.info(`Fetching single Discord channel: ${channelId}`) + + // Fetch a specific channel by ID + const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { + method: 'GET', + headers: { + Authorization: `Bot ${botToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('Discord API error fetching channel:', { + status: response.status, + statusText: response.statusText, + }) + + let errorMessage + try { + const errorData = await response.json() + logger.error('Error details:', errorData) + errorMessage = errorData.message || `Failed to fetch channel (${response.status})` + } catch (e) { + errorMessage = `Failed to fetch channel: ${response.status} ${response.statusText}` + } + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const channel = (await response.json()) as DiscordChannel + + // Verify this is a text channel and belongs to the requested server + if (channel.guild_id !== serverId) { + logger.error('Channel does not belong to the specified server') + return NextResponse.json( + { error: 'Channel not found in specified server' }, + { status: 404 } + ) + } + + if (channel.type !== 0) { + logger.warn('Requested channel is not a text channel') + return NextResponse.json({ error: 'Channel is not a text channel' }, { status: 400 }) + } + + logger.info(`Successfully fetched channel: ${channel.name}`) + + return NextResponse.json({ + channel: { + id: channel.id, + name: channel.name, + type: channel.type, + }, + }) + } + + logger.info(`Fetching all Discord channels for server: ${serverId}`) + + // Fetch all channels from Discord API + const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}/channels`, { + method: 'GET', + headers: { + Authorization: `Bot ${botToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('Discord API error:', { + status: response.status, + statusText: response.statusText, + }) + + let errorMessage + try { + const errorData = await response.json() + logger.error('Error details:', errorData) + errorMessage = errorData.message || `Failed to fetch channels (${response.status})` + } catch (e) { + errorMessage = `Failed to fetch channels: ${response.status} ${response.statusText}` + } + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const channels = (await response.json()) as DiscordChannel[] + + // Filter to just text channels (type 0) + const textChannels = channels.filter((channel: DiscordChannel) => channel.type === 0) + + logger.info(`Successfully fetched ${textChannels.length} text channels`) + + return NextResponse.json({ + channels: textChannels.map((channel: DiscordChannel) => ({ + id: channel.id, + name: channel.name, + type: channel.type, + })), + }) + } catch (error) { + logger.error('Error processing request:', error) + return NextResponse.json( + { + error: 'Failed to retrieve Discord channels', + details: (error as Error).message, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/auth/oauth/discord/servers/route.ts b/apps/sim/app/api/auth/oauth/discord/servers/route.ts new file mode 100644 index 000000000..72959867b --- /dev/null +++ b/apps/sim/app/api/auth/oauth/discord/servers/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' + +interface DiscordServer { + id: string + name: string + icon: string | null +} + +const logger = createLogger('DiscordServersAPI') + +export async function POST(request: Request) { + try { + const { botToken, serverId } = await request.json() + + if (!botToken) { + logger.error('Missing bot token in request') + return NextResponse.json({ error: 'Bot token is required' }, { status: 400 }) + } + + // If serverId is provided, we'll fetch just that server + if (serverId) { + logger.info(`Fetching single Discord server: ${serverId}`) + + // Fetch a specific server by ID + const response = await fetch(`https://discord.com/api/v10/guilds/${serverId}`, { + method: 'GET', + headers: { + Authorization: `Bot ${botToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('Discord API error fetching server:', { + status: response.status, + statusText: response.statusText, + }) + + let errorMessage + try { + const errorData = await response.json() + logger.error('Error details:', errorData) + errorMessage = errorData.message || `Failed to fetch server (${response.status})` + } catch (e) { + errorMessage = `Failed to fetch server: ${response.status} ${response.statusText}` + } + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const server = (await response.json()) as DiscordServer + logger.info(`Successfully fetched server: ${server.name}`) + + return NextResponse.json({ + server: { + id: server.id, + name: server.name, + icon: server.icon + ? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png` + : null, + }, + }) + } + + // Otherwise, fetch all servers the bot is in + logger.info('Fetching all Discord servers') + + const response = await fetch('https://discord.com/api/v10/users/@me/guilds', { + method: 'GET', + headers: { + Authorization: `Bot ${botToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('Discord API error:', { + status: response.status, + statusText: response.statusText, + }) + + let errorMessage + try { + const errorData = await response.json() + logger.error('Error details:', errorData) + errorMessage = errorData.message || `Failed to fetch servers (${response.status})` + } catch (e) { + errorMessage = `Failed to fetch servers: ${response.status} ${response.statusText}` + } + return NextResponse.json({ error: errorMessage }, { status: response.status }) + } + + const servers = (await response.json()) as DiscordServer[] + logger.info(`Successfully fetched ${servers.length} servers`) + + return NextResponse.json({ + servers: servers.map((server: DiscordServer) => ({ + id: server.id, + name: server.name, + icon: server.icon + ? `https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png` + : null, + })), + }) + } catch (error) { + logger.error('Error processing request:', error) + return NextResponse.json( + { + error: 'Failed to retrieve Discord servers', + details: (error as Error).message, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/auth/oauth/jira/issue/route.ts b/apps/sim/app/api/auth/oauth/jira/issue/route.ts index 0a10adc82..7be49e866 100644 --- a/apps/sim/app/api/auth/oauth/jira/issue/route.ts +++ b/apps/sim/app/api/auth/oauth/jira/issue/route.ts @@ -92,7 +92,6 @@ export async function POST(request: Request) { { error: 'Failed to retrieve Jira issue', details: (error as Error).message, - stack: (error as Error).stack, }, { status: 500 } ) diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx index 57906b617..b204eff80 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/credential-selector/components/oauth-required-modal.tsx @@ -87,6 +87,11 @@ const SCOPE_DESCRIPTIONS: Record = { 'read:user:jira': 'Read your Jira user', 'read:field-configuration:jira': 'Read your Jira field configuration', 'read:issue-details:jira': 'Read your Jira issue details', + 'identify': 'Read your Discord user', + 'bot': 'Read your Discord bot', + 'messages.read': 'Read your Discord messages', + 'guilds': 'Read your Discord guilds', + 'guilds.members.read': 'Read your Discord guild members', } // Convert OAuth scope to user-friendly description diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx new file mode 100644 index 000000000..f48c93c64 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/components/discord-channel-selector.tsx @@ -0,0 +1,320 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' +import { DiscordIcon } 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' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('DiscordChannelSelector') + +export interface DiscordChannelInfo { + id: string + name: string + type: number +} + +interface DiscordChannelSelectorProps { + value: string + onChange: (value: string, channelInfo?: DiscordChannelInfo) => void + botToken: string + serverId: string + label?: string + disabled?: boolean + showPreview?: boolean + onChannelInfoChange?: (info: DiscordChannelInfo | null) => void +} + +export function DiscordChannelSelector({ + value, + onChange, + botToken, + serverId, + label = 'Select Discord channel', + disabled = false, + showPreview = true, + onChannelInfoChange, +}: DiscordChannelSelectorProps) { + const [open, setOpen] = useState(false) + const [channels, setChannels] = useState([]) + const [selectedChannelId, setSelectedChannelId] = useState(value) + const [selectedChannel, setSelectedChannel] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [initialFetchDone, setInitialFetchDone] = useState(false) + + // Fetch channels from Discord API + const fetchChannels = useCallback(async () => { + if (!botToken || !serverId) { + setError(!botToken ? 'Bot token is required' : 'Server ID is required') + return + } + + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/auth/oauth/discord/channels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ botToken, serverId }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to fetch Discord channels') + } + + const data = await response.json() + setChannels(data.channels || []) + + // If we have a selected channel ID, find the channel info + const currentSelectedId = selectedChannelId // Store in local variable + if (currentSelectedId) { + const channelInfo = data.channels?.find( + (channel: DiscordChannelInfo) => channel.id === currentSelectedId + ) + if (channelInfo) { + setSelectedChannel(channelInfo) + onChannelInfoChange?.(channelInfo) + } + } + } catch (error) { + logger.error('Error fetching channels:', error) + setError((error as Error).message) + setChannels([]) + } finally { + setIsLoading(false) + setInitialFetchDone(true) + } + }, [botToken, serverId, selectedChannelId, onChannelInfoChange]) + + // Handle open change - only fetch channels when the dropdown is opened + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + + // Only fetch channels when opening the dropdown and if we have valid token and server + if (isOpen && botToken && serverId && (!initialFetchDone || channels.length === 0)) { + fetchChannels() + } + } + + // Fetch only the selected channel info when component mounts or when selectedChannelId changes + // This is more efficient than fetching all channels + const fetchSelectedChannelInfo = useCallback(async () => { + if (!botToken || !serverId || !selectedChannelId) return + + setIsLoading(true) + setError(null) + + try { + // Only fetch the specific channel by ID instead of all channels + const response = await fetch('/api/auth/oauth/discord/channels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + botToken, + serverId, + channelId: selectedChannelId, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to fetch Discord channel') + } + + const data = await response.json() + if (data.channel) { + setSelectedChannel(data.channel) + onChannelInfoChange?.(data.channel) + } else if (data.channels && data.channels.length > 0) { + const channelInfo = data.channels.find( + (channel: DiscordChannelInfo) => channel.id === selectedChannelId + ) + if (channelInfo) { + setSelectedChannel(channelInfo) + onChannelInfoChange?.(channelInfo) + } + } + } catch (error) { + logger.error('Error fetching channel info:', error) + setError((error as Error).message) + } finally { + setIsLoading(false) + } + }, [botToken, serverId, selectedChannelId, onChannelInfoChange]) + + // Fetch selected channel info when component mounts or dependencies change + useEffect(() => { + if (value && botToken && serverId && (!selectedChannel || selectedChannel.id !== value)) { + fetchSelectedChannelInfo() + } + }, [value, botToken, serverId, selectedChannel, fetchSelectedChannelInfo]) + + // Sync with external value + useEffect(() => { + if (value !== selectedChannelId) { + setSelectedChannelId(value) + + // Find channel info for the new value + if (value && channels.length > 0) { + const channelInfo = channels.find((channel) => channel.id === value) + setSelectedChannel(channelInfo || null) + onChannelInfoChange?.(channelInfo || null) + } else if (value) { + // If we have a value but no channel info, we might need to fetch it + if (!selectedChannel || selectedChannel.id !== value) { + fetchSelectedChannelInfo() + } + } else { + setSelectedChannel(null) + onChannelInfoChange?.(null) + } + } + }, [ + value, + channels, + selectedChannelId, + selectedChannel, + fetchSelectedChannelInfo, + onChannelInfoChange, + ]) + + // Handle channel selection + const handleSelectChannel = (channel: DiscordChannelInfo) => { + setSelectedChannelId(channel.id) + setSelectedChannel(channel) + onChange(channel.id, channel) + onChannelInfoChange?.(channel) + setOpen(false) + } + + // Clear selection + const handleClearSelection = () => { + setSelectedChannelId('') + setSelectedChannel(null) + onChange('', undefined) + onChannelInfoChange?.(null) + setError(null) + } + + return ( +
+ + + + + + + + + + {isLoading ? ( +
+ + Loading channels... +
+ ) : error ? ( +
+

{error}

+
+ ) : channels.length === 0 ? ( +
+

No channels found

+

+ The bot needs access to view channels in this server +

+
+ ) : ( +
+

No matching channels

+
+ )} +
+ + {channels.length > 0 && ( + +
+ Channels +
+ {channels.map((channel) => ( + handleSelectChannel(channel)} + className="cursor-pointer" + > +
+ # + {channel.name} +
+ {channel.id === selectedChannelId && } +
+ ))} +
+ )} +
+
+
+
+ + {/* Channel preview */} + {showPreview && selectedChannel && ( +
+
+ +
+
+
+ # +
+
+

{selectedChannel.name}

+
Channel ID: {selectedChannel.id}
+
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index 183629156..14cfc1232 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -6,6 +6,8 @@ import { SubBlockConfig } from '@/blocks/types' import { ConfluenceFileInfo, ConfluenceFileSelector } from './components/confluence-file-selector' import { FileInfo, GoogleDrivePicker } from './components/google-drive-picker' import { JiraIssueInfo, JiraIssueSelector } from './components/jira-issue-selector' +import { DiscordChannelInfo, DiscordChannelSelector } from './components/discord-channel-selector' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' interface FileSelectorInputProps { blockId: string @@ -19,16 +21,21 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS const [fileInfo, setFileInfo] = useState(null) const [selectedIssueId, setSelectedIssueId] = useState('') const [issueInfo, setIssueInfo] = useState(null) + const [selectedChannelId, setSelectedChannelId] = useState('') + const [channelInfo, setChannelInfo] = useState(null) // Get provider-specific values const provider = subBlock.provider || 'google-drive' const isConfluence = provider === 'confluence' const isJira = provider === 'jira' + const isDiscord = provider === 'discord' // For Confluence and Jira, we need the domain and credentials const domain = isConfluence || isJira ? (getValue(blockId, 'domain') as string) || '' : '' - const credentials = - isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : '' + const credentials = isConfluence || isJira ? (getValue(blockId, 'credential') as string) || '' : '' + // For Discord, we need the bot token and server ID + const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : '' + const serverId = isDiscord ? (getValue(blockId, 'serverId') as string) || '' : '' // Get the current value from the store useEffect(() => { @@ -36,11 +43,13 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS if (value && typeof value === 'string') { if (isJira) { setSelectedIssueId(value) + } else if (isDiscord) { + setSelectedChannelId(value) } else { setSelectedFileId(value) } } - }, [blockId, subBlock.id, getValue, isJira]) + }, [blockId, subBlock.id, getValue, isJira, isDiscord]) // Handle file selection const handleFileChange = (fileId: string, info?: any) => { @@ -62,42 +71,103 @@ export function FileSelectorInput({ blockId, subBlock, disabled = false }: FileS } } + // Handle channel selection + const handleChannelChange = (channelId: string, info?: DiscordChannelInfo) => { + setSelectedChannelId(channelId) + setChannelInfo(info || null) + setValue(blockId, subBlock.id, channelId) + } + // For Google Drive const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '' const apiKey = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || '' + // Render Discord channel selector + if (isDiscord) { + return ( + + + +
+ +
+
+ {(!botToken || !serverId) && ( + +

{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}

+
+ )} +
+
+ ) + } + // Render the appropriate picker based on provider if (isConfluence) { return ( - void} - /> + + + +
+ void} + /> +
+
+ {!domain && ( + +

Please enter a Confluence domain first

+
+ )} +
+
) } if (isJira) { return ( - void} - /> + + + +
+ void} + /> +
+
+ {!domain && ( + +

Please enter a Jira domain first

+
+ )} +
+
) } diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx new file mode 100644 index 000000000..bee8b3966 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/components/discord-server-selector.tsx @@ -0,0 +1,326 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-react' +import { DiscordIcon } 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' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('DiscordServerSelector') + +export interface DiscordServerInfo { + id: string + name: string + icon?: string | null +} + +interface DiscordServerSelectorProps { + value: string + onChange: (value: string, serverInfo?: DiscordServerInfo) => void + botToken: string + label?: string + disabled?: boolean + showPreview?: boolean +} + +export function DiscordServerSelector({ + value, + onChange, + botToken, + label = 'Select Discord server', + disabled = false, + showPreview = true, +}: DiscordServerSelectorProps) { + const [open, setOpen] = useState(false) + const [servers, setServers] = useState([]) + const [selectedServerId, setSelectedServerId] = useState(value) + const [selectedServer, setSelectedServer] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [initialFetchDone, setInitialFetchDone] = useState(false) + + // Fetch servers from Discord API + const fetchServers = useCallback(async () => { + if (!botToken) { + setError('Bot token is required') + return + } + + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/auth/oauth/discord/servers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ botToken }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to fetch Discord servers') + } + + const data = await response.json() + setServers(data.servers || []) + + // If we have a selected server ID, find the server info + if (selectedServerId) { + const serverInfo = data.servers?.find( + (server: DiscordServerInfo) => server.id === selectedServerId + ) + if (serverInfo) { + setSelectedServer(serverInfo) + } + } + } catch (error) { + logger.error('Error fetching servers:', error) + setError((error as Error).message) + setServers([]) + } finally { + setIsLoading(false) + setInitialFetchDone(true) + } + }, [botToken, selectedServerId]) + + // Handle open change - only fetch servers when the dropdown is opened + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + + // Only fetch servers when opening the dropdown and if we have a valid token + if (isOpen && botToken && (!initialFetchDone || servers.length === 0)) { + fetchServers() + } + } + + // Fetch only the selected server info when component mounts or when selectedServerId changes + // This is more efficient than fetching all servers + const fetchSelectedServerInfo = useCallback(async () => { + if (!botToken || !selectedServerId) return + + setIsLoading(true) + setError(null) + + try { + // Only fetch the specific server by ID instead of all servers + const response = await fetch('/api/auth/oauth/discord/servers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + botToken, + serverId: selectedServerId + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to fetch Discord server') + } + + const data = await response.json() + if (data.server) { + setSelectedServer(data.server) + } else if (data.servers && data.servers.length > 0) { + const serverInfo = data.servers.find( + (server: DiscordServerInfo) => server.id === selectedServerId + ) + if (serverInfo) { + setSelectedServer(serverInfo) + } + } + } catch (error) { + logger.error('Error fetching server info:', error) + setError((error as Error).message) + } finally { + setIsLoading(false) + } + }, [botToken, selectedServerId]) + + // Fetch selected server info when component mounts or selectedServerId changes + useEffect(() => { + if (value && botToken && (!selectedServer || selectedServer.id !== value)) { + fetchSelectedServerInfo() + } + }, [value, botToken, selectedServer, fetchSelectedServerInfo]) + + // Sync with external value + useEffect(() => { + if (value !== selectedServerId) { + setSelectedServerId(value) + + // Find server info for the new value + if (value && servers.length > 0) { + const serverInfo = servers.find(server => server.id === value) + setSelectedServer(serverInfo || null) + } else if (value) { + // If we have a value but no server info, we might need to fetch it + if (!selectedServer || selectedServer.id !== value) { + fetchSelectedServerInfo() + } + } else { + setSelectedServer(null) + } + } + }, [value, servers, selectedServerId, selectedServer, fetchSelectedServerInfo]) + + // Handle server selection + const handleSelectServer = (server: DiscordServerInfo) => { + setSelectedServerId(server.id) + setSelectedServer(server) + onChange(server.id, server) + setOpen(false) + } + + // Clear selection + const handleClearSelection = () => { + setSelectedServerId('') + setSelectedServer(null) + onChange('', undefined) + setError(null) + } + + return ( +
+ + + + + + + + + + {isLoading ? ( +
+ + Loading servers... +
+ ) : error ? ( +
+

{error}

+
+ ) : servers.length === 0 ? ( +
+

No servers found

+

+ Make sure your bot is added to at least one server +

+
+ ) : ( +
+

No matching servers

+
+ )} +
+ + {servers.length > 0 && ( + +
+ Servers +
+ {servers.map((server) => ( + handleSelectServer(server)} + className="cursor-pointer" + > +
+ {server.icon ? ( + {server.name} + ) : ( + + )} + {server.name} +
+ {server.id === selectedServerId && } +
+ ))} +
+ )} +
+
+
+
+ + {/* Server preview */} + {showPreview && selectedServer && ( +
+
+ +
+
+
+ {selectedServer.icon ? ( + {selectedServer.name} + ) : ( + + )} +
+
+

{selectedServer.name}

+
+ Server ID: {selectedServer.id} +
+
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx index 201a0df39..17aeb492e 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/project-selector/project-selector-input.tsx @@ -1,8 +1,10 @@ 'use client' import { useEffect, useState } from 'react' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { SubBlockConfig } from '@/blocks/types' +import { DiscordServerInfo, DiscordServerSelector } from './components/discord-server-selector' import { JiraProjectInfo, JiraProjectSelector } from './components/jira-project-selector' interface ProjectSelectorInputProps { @@ -20,14 +22,15 @@ export function ProjectSelectorInput({ }: ProjectSelectorInputProps) { const { getValue, setValue } = useSubBlockStore() const [selectedProjectId, setSelectedProjectId] = useState('') - const [projectInfo, setProjectInfo] = useState(null) + const [projectInfo, setProjectInfo] = useState(null) // Get provider-specific values const provider = subBlock.provider || 'jira' + const isDiscord = provider === 'discord' // For Jira, we need the domain - const domain = (getValue(blockId, 'domain') as string) || '' - const credentials = (getValue(blockId, 'credential') as string) || '' + const domain = !isDiscord ? (getValue(blockId, 'domain') as string) || '' : '' + const botToken = isDiscord ? (getValue(blockId, 'botToken') as string) || '' : '' // Get the current value from the store useEffect(() => { @@ -38,7 +41,7 @@ export function ProjectSelectorInput({ }, [blockId, subBlock.id, getValue]) // Handle project selection - const handleProjectChange = (projectId: string, info?: JiraProjectInfo) => { + const handleProjectChange = (projectId: string, info?: JiraProjectInfo | DiscordServerInfo) => { setSelectedProjectId(projectId) setProjectInfo(info || null) setValue(blockId, subBlock.id, projectId) @@ -48,23 +51,68 @@ export function ProjectSelectorInput({ setValue(blockId, 'summary', '') setValue(blockId, 'description', '') setValue(blockId, 'issueKey', '') + } else if (provider === 'discord') { + setValue(blockId, 'channelId', '') } onProjectSelect?.(projectId) } + // Render Discord server selector if provider is discord + if (isDiscord) { + return ( + + + +
+ { + handleProjectChange(serverId, serverInfo) + }} + botToken={botToken} + label={subBlock.placeholder || 'Select Discord server'} + disabled={disabled || !botToken} + showPreview={true} + /> +
+
+ {!botToken && ( + +

Please enter a Bot Token first

+
+ )} +
+
+ ) + } + + // Default to Jira project selector return ( - + + + +
+ +
+
+ {!domain && ( + +

Please enter a Jira domain first

+
+ )} +
+
) } diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index 8fa621f5a..a1ff3ed5c 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -673,7 +673,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { const toolBlock = !isCustomTool ? toolBlocks.find((block) => block.type === tool.type) : null - const toolId = !isCustomTool ? getToolIdFromBlock(tool.type) : null + const toolId = !isCustomTool ? (tool.operation ?? getToolIdFromBlock(tool.type)) : null const hasOperations = !isCustomTool && hasMultipleOperations(tool.type) const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] @@ -834,7 +834,7 @@ export function ToolInput({ blockId, subBlockId }: ToolInputProps) { value={tool.operation || operationOptions[0].id} onValueChange={(value) => handleOperationChange(toolIndex, value)} > - + diff --git a/apps/sim/blocks/blocks/discord.ts b/apps/sim/blocks/blocks/discord.ts new file mode 100644 index 000000000..dfe62dee6 --- /dev/null +++ b/apps/sim/blocks/blocks/discord.ts @@ -0,0 +1,159 @@ +import { DiscordIcon } from '@/components/icons' +import { DiscordResponse } from '@/tools/discord/types' +import { BlockConfig } from '../types' + +export const DiscordBlock: BlockConfig = { + type: 'discord', + name: 'Discord', + description: 'Interact with Discord', + longDescription: + 'Connect to Discord to send messages, manage channels, and interact with servers. Automate notifications, community management, and integrate Discord into your workflows.', + category: 'tools', + bgColor: '#E0E0E0', + icon: DiscordIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Send Message', id: 'discord_send_message' }, + { label: 'Get Channel Messages', id: 'discord_get_messages' }, + { label: 'Get Server Information', id: 'discord_get_server' }, + { label: 'Get User Information', id: 'discord_get_user' }, + ], + }, + { + id: 'botToken', + title: 'Bot Token', + type: 'short-input', + layout: 'full', + placeholder: 'Enter Discord bot token', + password: true, + }, + { + id: 'serverId', + title: 'Server', + type: 'project-selector', + layout: 'full', + provider: 'discord', + serviceId: 'discord', + placeholder: 'Select Discord server', + condition: { + field: 'operation', + value: ['discord_send_message', 'discord_get_messages', 'discord_get_server'], + }, + }, + { + id: 'channelId', + title: 'Channel', + type: 'file-selector', + layout: 'full', + provider: 'discord', + serviceId: 'discord', + placeholder: 'Select Discord channel', + condition: { field: 'operation', value: ['discord_send_message', 'discord_get_messages'] }, + }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + layout: 'full', + placeholder: 'Enter Discord user ID', + condition: { field: 'operation', value: 'discord_get_user' }, + }, + { + id: 'limit', + title: 'Message Limit', + type: 'short-input', + layout: 'half', + placeholder: 'Number of messages (default: 10, max: 100)', + condition: { field: 'operation', value: 'discord_get_messages' }, + }, + { + id: 'content', + title: 'Message Content', + type: 'long-input', + layout: 'full', + placeholder: 'Enter message content...', + condition: { field: 'operation', value: 'discord_send_message' }, + }, + ], + tools: { + access: [ + 'discord_send_message', + 'discord_get_messages', + 'discord_get_server', + 'discord_get_user', + ], + config: { + tool: (params) => { + switch (params.operation) { + case 'discord_send_message': + return 'discord_send_message' + case 'discord_get_messages': + return 'discord_get_messages' + case 'discord_get_server': + return 'discord_get_server' + case 'discord_get_user': + return 'discord_get_user' + default: + return 'discord_send_message' + } + }, + params: (params) => { + const commonParams: Record = {} + + if (!params.botToken) throw new Error('Bot token required for this operation') + commonParams.botToken = params.botToken + + switch (params.operation) { + case 'discord_send_message': + return { + ...commonParams, + serverId: params.serverId, + channelId: params.channelId, + content: params.content, + } + case 'discord_get_messages': + return { + ...commonParams, + serverId: params.serverId, + channelId: params.channelId, + limit: params.limit ? Math.min(Math.max(1, Number(params.limit)), 100) : 10, + } + case 'discord_get_server': + return { + ...commonParams, + serverId: params.serverId, + } + case 'discord_get_user': + return { + ...commonParams, + userId: params.userId, + } + default: + return commonParams + } + }, + }, + }, + inputs: { + operation: { type: 'string', required: true }, + botToken: { type: 'string', required: true }, + serverId: { type: 'string', required: false }, + channelId: { type: 'string', required: false }, + content: { type: 'string', required: false }, + limit: { type: 'number', required: false }, + userId: { type: 'string', required: false }, + }, + outputs: { + response: { + type: { + message: 'string', + data: 'any', + }, + }, + }, +} diff --git a/apps/sim/blocks/blocks/elevenlabs.ts b/apps/sim/blocks/blocks/elevenlabs.ts index 67e3bf2f5..2c23fe87d 100644 --- a/apps/sim/blocks/blocks/elevenlabs.ts +++ b/apps/sim/blocks/blocks/elevenlabs.ts @@ -31,22 +31,10 @@ export const ElevenLabsBlock: BlockConfig = { }, inputs: { - text: { - type: 'string', - required: true, - }, - voiceId: { - type: 'string', - required: true, - }, - modelId: { - type: 'string', - required: false, - }, - apiKey: { - type: 'string', - required: true, - }, + text: { type: 'string', required: true }, + voiceId: { type: 'string', required: true }, + modelId: { type: 'string', required: false }, + apiKey: { type: 'string', required: true }, }, outputs: { diff --git a/apps/sim/blocks/blocks/google.ts b/apps/sim/blocks/blocks/google.ts index c29ac615f..677a3bf50 100644 --- a/apps/sim/blocks/blocks/google.ts +++ b/apps/sim/blocks/blocks/google.ts @@ -79,26 +79,10 @@ export const GoogleSearchBlock: BlockConfig = { }, inputs: { - query: { - type: 'string', - required: true, - description: 'The search query to execute', - }, - apiKey: { - type: 'string', - required: true, - description: 'Google API key', - }, - searchEngineId: { - type: 'string', - required: true, - description: 'Custom Search Engine ID', - }, - num: { - type: 'string', - required: false, - description: 'Number of results to return (default: 10, max: 10)', - }, + query: { type: 'string', required: true }, + apiKey: { type: 'string', required: true }, + searchEngineId: { type: 'string', required: true }, + num: { type: 'string', required: false }, }, outputs: { diff --git a/apps/sim/blocks/blocks/reddit.ts b/apps/sim/blocks/blocks/reddit.ts index 782c324d0..38c113460 100644 --- a/apps/sim/blocks/blocks/reddit.ts +++ b/apps/sim/blocks/blocks/reddit.ts @@ -170,48 +170,14 @@ export const RedditBlock: BlockConfig< }, }, inputs: { - action: { - type: 'string', - required: true, - description: 'The action to perform: get_posts or get_comments', - }, - subreddit: { - type: 'string', - required: true, - description: 'The name of the subreddit to fetch data from (without the r/ prefix)', - }, - sort: { - type: 'string', - required: true, - description: 'Sort method for posts: "hot", "new", "top", or "rising" (default: "hot")', - }, - time: { - type: 'string', - required: false, - description: - 'Time filter for "top" sorted posts: "hour", "day", "week", "month", "year", or "all" (default: "day")', - }, - limit: { - type: 'number', - required: false, - description: 'Maximum number of posts to return (default: 10, max: 100)', - }, - postId: { - type: 'string', - required: true, - description: 'The ID of the Reddit post to fetch comments from', - }, - commentSort: { - type: 'string', - required: false, - description: - 'Sort method for comments: "confidence", "top", "new", "controversial", "old", "random", "qa" (default: "confidence")', - }, - commentLimit: { - type: 'number', - required: false, - description: 'Maximum number of comments to return (default: 50, max: 100)', - }, + action: { type: 'string', required: true }, + subreddit: { type: 'string', required: true }, + sort: { type: 'string', required: true }, + time: { type: 'string', required: false }, + limit: { type: 'number', required: false }, + postId: { type: 'string', required: true }, + commentSort: { type: 'string', required: false }, + commentLimit: { type: 'number', required: false }, }, outputs: { response: { diff --git a/apps/sim/blocks/blocks/thinking.ts b/apps/sim/blocks/blocks/thinking.ts index 7420043cb..344bcdda2 100644 --- a/apps/sim/blocks/blocks/thinking.ts +++ b/apps/sim/blocks/blocks/thinking.ts @@ -31,11 +31,7 @@ export const ThinkingBlock: BlockConfig = { ], inputs: { - thought: { - type: 'string', - required: true, - description: 'The detailed thought process or instruction for the model.', - }, + thought: { type: 'string', required: true }, }, outputs: { diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 5478dee6a..4a44fdaa4 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -11,6 +11,7 @@ import { BrowserUseBlock } from './blocks/browser_use' import { ClayBlock } from './blocks/clay' import { ConditionBlock } from './blocks/condition' import { ConfluenceBlock } from './blocks/confluence' +import { DiscordBlock } from './blocks/discord' import { ElevenLabsBlock } from './blocks/elevenlabs' import { EvaluatorBlock } from './blocks/evaluator' import { ExaBlock } from './blocks/exa' @@ -65,6 +66,7 @@ export const registry: Record = { clay: ClayBlock, condition: ConditionBlock, confluence: ConfluenceBlock, + discord: DiscordBlock, elevenlabs: ElevenLabsBlock, evaluator: EvaluatorBlock, exa: ExaBlock, @@ -80,8 +82,8 @@ export const registry: Record = { // guesty: GuestyBlock, image_generator: ImageGeneratorBlock, jina: JinaBlock, - linkup: LinkupBlock, jira: JiraBlock, + linkup: LinkupBlock, mem0: Mem0Block, mistral_parse: MistralParseBlock, notion: NotionBlock, @@ -98,6 +100,7 @@ export const registry: Record = { starter: StarterBlock, supabase: SupabaseBlock, tavily: TavilyBlock, + telegram: TelegramBlock, thinking: ThinkingBlock, translate: TranslateBlock, twilio_sms: TwilioSMSBlock, @@ -106,7 +109,6 @@ export const registry: Record = { whatsapp: WhatsAppBlock, x: XBlock, youtube: YouTubeBlock, - telegram: TelegramBlock, } // Helper functions to access the registry diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 87a9f52e6..9099d960c 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -29,7 +29,7 @@ export type SubBlockType = | 'webhook-config' // Webhook configuration | 'schedule-config' // Schedule status and information | 'file-selector' // File selector for Google Drive, etc. - | 'project-selector' // Project selector for Jira + | 'project-selector' // Project selector for Jira, Discord, etc. | 'folder-selector' // Folder selector for Gmail, etc. | 'input-format' // Input structure format | 'file-upload' // File uploader diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 2277c3dab..cb0956543 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1359,9 +1359,13 @@ export function GoogleIcon(props: SVGProps) { export function DiscordIcon(props: SVGProps) { return ( - - - + + + + + + + ) } diff --git a/apps/sim/lib/auth.ts b/apps/sim/lib/auth.ts index 1e459c80f..0c6290fe0 100644 --- a/apps/sim/lib/auth.ts +++ b/apps/sim/lib/auth.ts @@ -79,18 +79,18 @@ export const auth = betterAuth({ .from(schema.member) .where(eq(schema.member.userId, session.userId)) .limit(1) - + if (members.length > 0) { - logger.info('Found organization for user', { - userId: session.userId, - organizationId: members[0].organizationId, + logger.info('Found organization for user', { + userId: session.userId, + organizationId: members[0].organizationId }) - + return { data: { ...session, - activeOrganizationId: members[0].organizationId, - }, + activeOrganizationId: members[0].organizationId + } } } else { logger.info('No organizations found for user', { userId: session.userId }) @@ -529,6 +529,61 @@ export const auth = betterAuth({ }, }, + // Discord provider + { + providerId: 'discord', + clientId: process.env.DISCORD_CLIENT_ID as string, + clientSecret: process.env.DISCORD_CLIENT_SECRET as string, + authorizationUrl: 'https://discord.com/api/oauth2/authorize', + tokenUrl: 'https://discord.com/api/oauth2/token', + userInfoUrl: 'https://discord.com/api/users/@me', + scopes: [ + 'identify', + 'bot', + 'messages.read', + 'guilds', + 'guilds.members.read', + ], + responseType: 'code', + accessType: 'offline', + authentication: 'basic', + prompt: 'consent', + redirectURI: `${process.env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/discord`, + getUserInfo: async (tokens) => { + try { + const response = await fetch('https://discord.com/api/users/@me', { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + + if (!response.ok) { + logger.error('Error fetching Discord user info:', { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const profile = await response.json() + const now = new Date() + + return { + id: profile.id, + name: profile.username || 'Discord User', + email: profile.email || `${profile.id}@discord.user`, + image: profile.avatar ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` : null, + emailVerified: profile.verified || false, + createdAt: now, + updatedAt: now, + } + } catch (error) { + logger.error('Error in Discord getUserInfo:', { error }) + return null + } + }, + }, + // Jira provider { providerId: 'jira', diff --git a/apps/sim/lib/oauth.ts b/apps/sim/lib/oauth.ts index c92d6d765..486ca8a9a 100644 --- a/apps/sim/lib/oauth.ts +++ b/apps/sim/lib/oauth.ts @@ -13,6 +13,7 @@ import { NotionIcon, SupabaseIcon, xIcon, + DiscordIcon, } from '@/components/icons' import { createLogger } from '@/lib/logs/console-logger' @@ -28,6 +29,7 @@ export type OAuthProvider = | 'airtable' | 'notion' | 'jira' + | 'discord' | string export type OAuthService = | 'google' @@ -42,7 +44,8 @@ export type OAuthService = | 'airtable' | 'notion' | 'jira' - + | 'discord' + // Define the interface for OAuth provider configuration export interface OAuthProviderConfig { id: OAuthProvider @@ -236,6 +239,29 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'airtable', }, + discord: { + id: 'discord', + name: 'Discord', + icon: (props) => DiscordIcon(props), + services: { + discord: { + id: 'discord', + name: 'Discord', + description: 'Read and send messages to Discord channels and interact with servers.', + providerId: 'discord', + icon: (props) => DiscordIcon(props), + baseProviderIcon: (props) => DiscordIcon(props), + scopes: [ + 'identify', + 'bot', + 'messages.read', + 'guilds', + 'guilds.members.read', + ], + }, + }, + defaultService: 'discord', + }, notion: { id: 'notion', name: 'Notion', @@ -307,6 +333,8 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[] return 'airtable' } else if (provider === 'notion') { return 'notion' + } else if (provider === 'discord') { + return 'discord' } return providerConfig.defaultService @@ -430,6 +458,12 @@ export async function refreshOAuthToken( clientId = process.env.NOTION_CLIENT_ID clientSecret = process.env.NOTION_CLIENT_SECRET break + case 'discord': + tokenEndpoint = 'https://discord.com/api/v10/oauth2/token' + clientId = process.env.DISCORD_CLIENT_ID + clientSecret = process.env.DISCORD_CLIENT_SECRET + useBasicAuth = true + break default: throw new Error(`Unsupported provider: ${provider}`) } @@ -467,9 +501,7 @@ export async function refreshOAuthToken( } else { throw new Error('Both client ID and client secret are required for Airtable OAuth') } - } else if (provider === 'x' || provider === 'confluence' || provider === 'jira') { - // Handle X and Atlassian services (Confluence, Jira) the same way - // Confidential client - use Basic Auth + } else if (provider === 'x' || provider === 'confluence' || provider === 'jira' || provider === 'discord') { const authString = `${clientId}:${clientSecret}` const basicAuth = Buffer.from(authString).toString('base64') headers['Authorization'] = `Basic ${basicAuth}` diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 7b950770d..efe0a027a 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -1149,7 +1149,6 @@ export async function fetchAndProcessAirtablePayloads( webhookId: webhookData.id, workflowId: workflowData.id, error: (error as Error).message, - stack: (error as Error).stack, } ) // Persist this higher-level error diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index c2ee00a88..b0385cbd8 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -112,7 +112,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-insights.com https://vercel.live https://*.vercel.live; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${process.env.OLLAMA_HOST || 'http://localhost:11434'} https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live; 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-insights.com https://vercel.live https://*.vercel.live; 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; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ${process.env.OLLAMA_HOST || 'http://localhost:11434'} https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.vercel-insights.com https://*.atlassian.com https://vercel.live https://*.vercel.live; frame-src https://drive.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'`, }, ], }, diff --git a/apps/sim/tools/discord/get_messages.ts b/apps/sim/tools/discord/get_messages.ts new file mode 100644 index 000000000..1cd8ef896 --- /dev/null +++ b/apps/sim/tools/discord/get_messages.ts @@ -0,0 +1,107 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { ToolConfig } from '../types' +import { + DiscordAPIError, + DiscordGetMessagesParams, + DiscordGetMessagesResponse, + DiscordMessage, +} from './types' + +const logger = createLogger('DiscordGetMessages') + +export const discordGetMessagesTool: ToolConfig< + DiscordGetMessagesParams, + DiscordGetMessagesResponse +> = { + id: 'discord_get_messages', + name: 'Discord Get Messages', + description: 'Retrieve messages from a Discord channel', + version: '1.0.0', + + params: { + botToken: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'The bot token for authentication', + }, + channelId: { + type: 'string', + required: true, + optionalToolInput: true, + description: 'The Discord channel ID to retrieve messages from', + }, + limit: { + type: 'number', + required: false, + description: 'Maximum number of messages to retrieve (default: 10, max: 100)', + }, + }, + + request: { + url: (params: DiscordGetMessagesParams) => { + const limit = Math.min(params.limit || 10, 100) + return `https://discord.com/api/v10/channels/${params.channelId}/messages?limit=${limit}` + }, + method: 'GET', + headers: (params) => { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (params.botToken) { + headers['Authorization'] = `Bot ${params.botToken}` + } + + return headers + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + let errorMessage = `Failed to get Discord messages: ${response.status} ${response.statusText}` + + try { + const errorData = (await response.json()) as DiscordAPIError + errorMessage = `Failed to get Discord messages: ${errorData.message || response.statusText}` + logger.error('Discord API error', { status: response.status, error: errorData }) + } catch (e) { + logger.error('Error parsing Discord API response', { status: response.status, error: e }) + } + + return { + success: false, + output: { + message: errorMessage, + }, + error: errorMessage, + } + } + + let messages: DiscordMessage[] + try { + messages = await response.json() + } catch (e) { + return { + success: false, + error: 'Failed to parse messages', + output: { message: 'Failed to parse messages' }, + } + } + return { + success: true, + output: { + message: `Retrieved ${messages.length} messages from Discord channel`, + data: { + messages, + channel_id: messages.length > 0 ? messages[0].channel_id : '', + }, + }, + } + }, + + transformError: (error) => { + logger.error('Error retrieving Discord messages', { error }) + return `Error retrieving Discord messages: ${error instanceof Error ? error.message : String(error)}` + }, +} diff --git a/apps/sim/tools/discord/get_server.ts b/apps/sim/tools/discord/get_server.ts new file mode 100644 index 000000000..cf9820ad7 --- /dev/null +++ b/apps/sim/tools/discord/get_server.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { ToolConfig } from '../types' +import { + DiscordAPIError, + DiscordGetServerParams, + DiscordGetServerResponse, + DiscordGuild, +} from './types' + +const logger = createLogger('DiscordGetServer') + +export const discordGetServerTool: ToolConfig = { + id: 'discord_get_server', + name: 'Discord Get Server', + description: 'Retrieve information about a Discord server (guild)', + version: '1.0.0', + + params: { + botToken: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'The bot token for authentication', + }, + serverId: { + type: 'string', + required: true, + optionalToolInput: true, + description: 'The Discord server ID (guild ID)', + }, + }, + + request: { + url: (params: DiscordGetServerParams) => + `https://discord.com/api/v10/guilds/${params.serverId}`, + method: 'GET', + headers: (params: DiscordGetServerParams) => { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (params.botToken) { + headers['Authorization'] = `Bot ${params.botToken}` + } + + return headers + }, + }, + + transformResponse: async (response: Response) => { + let responseData: any + + try { + responseData = await response.json() + } catch (e) { + logger.error('Error parsing Discord API response', { status: response.status, error: e }) + return { + success: false, + error: 'Failed to parse server data', + output: { message: 'Failed to parse server data' }, + } + } + + if (!response.ok) { + const errorData = responseData as DiscordAPIError + const errorMessage = `Discord API error: ${errorData.message || response.statusText}` + + logger.error('Discord API error', { + status: response.status, + error: errorData, + }) + + return { + success: false, + output: { + message: errorMessage, + }, + error: errorMessage, + } + } + + return { + success: true, + output: { + message: 'Successfully retrieved server information', + data: responseData as DiscordGuild, + }, + } + }, + + transformError: (error: Error | unknown): string => { + logger.error('Error fetching Discord server', { error }) + return `Error fetching Discord server: ${error instanceof Error ? error.message : String(error)}` + }, +} diff --git a/apps/sim/tools/discord/get_user.ts b/apps/sim/tools/discord/get_user.ts new file mode 100644 index 000000000..c7ed08e65 --- /dev/null +++ b/apps/sim/tools/discord/get_user.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { ToolConfig } from '../types' +import { DiscordAPIError, DiscordGetUserParams, DiscordGetUserResponse, DiscordUser } from './types' + +const logger = createLogger('DiscordGetUser') + +export const discordGetUserTool: ToolConfig = { + id: 'discord_get_user', + name: 'Discord Get User', + description: 'Retrieve information about a Discord user', + version: '1.0.0', + + params: { + botToken: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'Discord bot token for authentication', + }, + userId: { + type: 'string', + required: true, + optionalToolInput: true, + description: 'The Discord user ID', + }, + }, + + request: { + url: (params: DiscordGetUserParams) => `https://discord.com/api/v10/users/${params.userId}`, + method: 'GET', + headers: (params: DiscordGetUserParams) => { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (params.botToken) { + headers['Authorization'] = `Bot ${params.botToken}` + } + + return headers + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + let errorMessage = `Failed to get Discord user: ${response.status} ${response.statusText}` + + try { + const errorData = (await response.json()) as DiscordAPIError + errorMessage = `Failed to get Discord user: ${errorData.message || response.statusText}` + logger.error('Discord API error', { status: response.status, error: errorData }) + } catch (e) { + logger.error('Error parsing Discord API response', { status: response.status, error: e }) + } + + return { + success: false, + output: { + message: errorMessage, + }, + error: errorMessage, + } + } + + let data: DiscordUser + try { + data = await response.clone().json() + } catch (e) { + return { + success: false, + error: 'Failed to parse user data', + output: { message: 'Failed to parse user data' }, + } + } + + return { + success: true, + output: { + message: `Retrieved information for Discord user: ${data.username}`, + data, + }, + } + }, + + transformError: (error) => { + logger.error('Error retrieving Discord user information', { error }) + return `Error retrieving Discord user information: ${error.error}` + }, +} diff --git a/apps/sim/tools/discord/index.ts b/apps/sim/tools/discord/index.ts new file mode 100644 index 000000000..f0816e2aa --- /dev/null +++ b/apps/sim/tools/discord/index.ts @@ -0,0 +1,6 @@ +import { discordGetMessagesTool } from './get_messages' +import { discordGetServerTool } from './get_server' +import { discordGetUserTool } from './get_user' +import { discordSendMessageTool } from './send_message' + +export { discordSendMessageTool, discordGetMessagesTool, discordGetServerTool, discordGetUserTool } diff --git a/apps/sim/tools/discord/send_message.ts b/apps/sim/tools/discord/send_message.ts new file mode 100644 index 000000000..b9a78fd4b --- /dev/null +++ b/apps/sim/tools/discord/send_message.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { ToolConfig } from '../types' +import { + DiscordAPIError, + DiscordMessage, + DiscordSendMessageParams, + DiscordSendMessageResponse, +} from './types' + +const logger = createLogger('DiscordSendMessage') + +export const discordSendMessageTool: ToolConfig< + DiscordSendMessageParams, + DiscordSendMessageResponse +> = { + id: 'discord_send_message', + name: 'Discord Send Message', + description: 'Send a message to a Discord channel', + version: '1.0.0', + + params: { + botToken: { + type: 'string', + required: true, + requiredForToolCall: true, + description: 'The bot token for authentication', + }, + channelId: { + type: 'string', + required: true, + optionalToolInput: true, + description: 'The Discord channel ID to send the message to', + }, + content: { + type: 'string', + required: false, + description: 'The text content of the message', + }, + serverId: { + type: 'string', + required: true, + optionalToolInput: true, + description: 'The Discord server ID (guild ID)', + }, + }, + + request: { + url: (params: DiscordSendMessageParams) => + `https://discord.com/api/v10/channels/${params.channelId}/messages`, + method: 'POST', + headers: (params: DiscordSendMessageParams) => { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (params.botToken) { + headers['Authorization'] = `Bot ${params.botToken}` + } + + return headers + }, + body: (params: DiscordSendMessageParams) => { + const body: Record = {} + + if (params.content) { + body.content = params.content + } + + if (!body.content) { + body.content = 'Message sent from Sim Studio' + } + + return body + }, + }, + + transformResponse: async (response) => { + if (!response.ok) { + let errorMessage = `Failed to send Discord message: ${response.status} ${response.statusText}` + + try { + const errorData = (await response.json()) as DiscordAPIError + errorMessage = `Failed to send Discord message: ${errorData.message || response.statusText}` + logger.error('Discord API error', { status: response.status, error: errorData }) + } catch (e) { + logger.error('Error parsing Discord API response', { status: response.status, error: e }) + } + + return { + success: false, + output: { + message: errorMessage, + }, + error: errorMessage, + } + } + + try { + const data = (await response.json()) as DiscordMessage + return { + success: true, + output: { + message: 'Discord message sent successfully', + data, + }, + } + } catch (e) { + logger.error('Error parsing successful Discord response', { error: e }) + return { + success: false, + error: 'Failed to parse Discord response', + output: { + message: 'Failed to parse Discord response', + }, + } + } + }, + + transformError: (error: Error | unknown): string => { + logger.error('Error sending Discord message', { error }) + return `Error sending Discord message: ${error instanceof Error ? error.message : String(error)}` + }, +} diff --git a/apps/sim/tools/discord/types.ts b/apps/sim/tools/discord/types.ts new file mode 100644 index 000000000..ff94b5099 --- /dev/null +++ b/apps/sim/tools/discord/types.ts @@ -0,0 +1,117 @@ +export interface DiscordMessage { + id: string + content: string + channel_id: string + author: { + id: string + username: string + avatar?: string + bot: boolean + } + timestamp: string + edited_timestamp?: string | null + embeds: any[] + attachments: any[] + mentions: any[] + mention_roles: string[] + mention_everyone: boolean +} + +export interface DiscordAPIError { + code: number + message: string + errors?: Record +} + +export interface DiscordGuild { + id: string + name: string + icon?: string + description?: string + owner_id: string + roles: any[] + channels?: any[] + member_count?: number +} + +export interface DiscordUser { + id: string + username: string + discriminator: string + avatar?: string + bot?: boolean + system?: boolean + email?: string + verified?: boolean +} + +export interface DiscordAuthParams { + botToken: string + serverId: string +} + +export interface DiscordSendMessageParams extends DiscordAuthParams { + channelId: string + content?: string + embed?: { + title?: string + description?: string + color?: string | number + } +} + +export interface DiscordGetMessagesParams extends DiscordAuthParams { + channelId: string + limit?: number +} + +export interface DiscordGetServerParams extends Omit { + serverId: string +} + +export interface DiscordGetUserParams extends Omit { + userId: string +} + +interface BaseDiscordResponse { + success: boolean + output: Record + error?: string +} + +export interface DiscordSendMessageResponse extends BaseDiscordResponse { + output: { + message: string + data?: DiscordMessage + } +} + +export interface DiscordGetMessagesResponse extends BaseDiscordResponse { + output: { + message: string + data?: { + messages: DiscordMessage[] + channel_id: string + } + } +} + +export interface DiscordGetServerResponse extends BaseDiscordResponse { + output: { + message: string + data?: DiscordGuild + } +} + +export interface DiscordGetUserResponse extends BaseDiscordResponse { + output: { + message: string + data?: DiscordUser + } +} + +export type DiscordResponse = + | DiscordSendMessageResponse + | DiscordGetMessagesResponse + | DiscordGetServerResponse + | DiscordGetUserResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index f87c1820f..a4dc45b82 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -64,6 +64,7 @@ import { visionTool } from './vision' import { whatsappSendMessageTool } from './whatsapp' import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x' import { youtubeSearchTool } from './youtube' +import { discordGetMessagesTool, discordGetServerTool, discordGetUserTool, discordSendMessageTool } from './discord' // Registry of all available tools export const tools: Record = { @@ -152,4 +153,8 @@ export const tools: Record = { s3_get_object: s3GetObjectTool, telegram_message: telegramMessageTool, clay_populate: clayPopulateTool, -} + discord_send_message: discordSendMessageTool, + discord_get_messages: discordGetMessagesTool, + discord_get_server: discordGetServerTool, + discord_get_user: discordGetUserTool, +} diff --git a/scripts/generate-block-docs.ts b/scripts/generate-block-docs.ts index 715ccf17d..8bed56f1b 100644 --- a/scripts/generate-block-docs.ts +++ b/scripts/generate-block-docs.ts @@ -261,7 +261,7 @@ function extractInputs(content: string): Record { const inputs: Record = {} // Find all input property definitions - const propMatches = inputsContent.match(/(\w+)\s*:\s*{[^}]*}/g) + const propMatches = inputsContent.match(/(\w+)\s*:\s*{[\s\S]*?}/g) if (!propMatches) { // Try an alternative approach for the whole inputs section const inputLines = inputsContent.split('\n') @@ -287,8 +287,9 @@ function extractInputs(content: string): Record { if (!propMatch) return const propName = propMatch[1] - const typeMatch = propText.match(/type\s*:\s*['"]?([^'"}, ]+)['"]?/) - const requiredMatch = propText.match(/required\s*:\s*(true|false)/) + const typeMatch = propText.match(/type\s*:\s*['"]?([^'"}, ]+)['"]?/s) + const requiredMatch = propText.match(/required\s*:\s*(true|false)/s) + const descriptionMatch = propText.match(/description\s*:\s*['"]([^'"]+)['"]/s) inputs[propName] = { type: typeMatch ? typeMatch[1] : 'any',