feat(discord): added discord block/tools (#347)

* feat(tools): added discord oauth, block, and tools

* added docs

* remove extraneous comments/logs

* added tooltips for disabled subblocks, acknowledged PR comments

* added tools and blocks for new repo structure

* added docs

* added more type safety, fixed some styling

* updated docs

* fixed style of dropdown in tool-input

* acknowledged PR comments
This commit is contained in:
Waleed Latif
2025-05-11 18:32:04 -07:00
committed by GitHub
parent 0fc0f683a6
commit 3580241d93
35 changed files with 2046 additions and 156 deletions

View File

@@ -0,0 +1,142 @@
---
title: Discord
description: Interact with Discord
---
import { BlockInfoCard } from '@/components/ui/block-info-card'
<BlockInfoCard
type="discord"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fillRule="nonzero">
</path>
</g>
</svg>`}
/>
{/* 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`

View File

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

View File

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

View File

@@ -6,6 +6,7 @@
"browser_use",
"clay",
"confluence",
"discord",
"dropdown",
"elevenlabs",
"exa",

View File

@@ -114,7 +114,7 @@ Fetch comments from a specific Reddit post
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ----------- |
| `action` | string | No | Action |
| `action` | string | Yes | Action |
### Outputs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DiscordChannelInfo[]>([])
const [selectedChannelId, setSelectedChannelId] = useState(value)
const [selectedChannel, setSelectedChannel] = useState<DiscordChannelInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-2">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled || !botToken || !serverId}
>
{selectedChannel ? (
<div className="flex items-center gap-2 overflow-hidden">
<span className="text-muted-foreground">#</span>
<span className="font-normal truncate">{selectedChannel.name}</span>
</div>
) : (
<div className="flex items-center gap-2">
<DiscordIcon className="h-4 w-4" />
<span className="text-muted-foreground">{label}</span>
</div>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
<Command>
<CommandInput placeholder="Search channels..." />
<CommandList>
<CommandEmpty>
{isLoading ? (
<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-sm text-destructive">{error}</p>
</div>
) : channels.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm font-medium">No channels found</p>
<p className="text-xs text-muted-foreground">
The bot needs access to view channels in this server
</p>
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No matching channels</p>
</div>
)}
</CommandEmpty>
{channels.length > 0 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
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">
<span className="text-muted-foreground">#</span>
<span className="font-normal truncate">{channel.name}</span>
</div>
{channel.id === selectedChannelId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Channel preview */}
{showPreview && selectedChannel && (
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-muted"
onClick={handleClearSelection}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-3 pr-4">
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded-full">
<span className="text-muted-foreground font-semibold">#</span>
</div>
<div className="overflow-hidden flex-1 min-w-0">
<h4 className="text-xs font-medium truncate">{selectedChannel.name}</h4>
<div className="text-xs text-muted-foreground">Channel ID: {selectedChannel.id}</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<FileInfo | ConfluenceFileInfo | null>(null)
const [selectedIssueId, setSelectedIssueId] = useState<string>('')
const [issueInfo, setIssueInfo] = useState<JiraIssueInfo | null>(null)
const [selectedChannelId, setSelectedChannelId] = useState<string>('')
const [channelInfo, setChannelInfo] = useState<DiscordChannelInfo | null>(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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<DiscordChannelSelector
value={selectedChannelId}
onChange={handleChannelChange}
botToken={botToken}
serverId={serverId}
label={subBlock.placeholder || 'Select Discord channel'}
disabled={disabled || !botToken || !serverId}
showPreview={true}
/>
</div>
</TooltipTrigger>
{(!botToken || !serverId) && (
<TooltipContent side="top">
<p>{!botToken ? 'Please enter a Bot Token first' : 'Please select a Server first'}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Render the appropriate picker based on provider
if (isConfluence) {
return (
<ConfluenceFileSelector
value={selectedFileId}
onChange={handleFileChange}
domain={domain}
provider="confluence"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<ConfluenceFileSelector
value={selectedFileId}
onChange={handleFileChange}
domain={domain}
provider="confluence"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Confluence page'}
disabled={disabled || !domain}
showPreview={true}
onFileInfoChange={setFileInfo as (info: ConfluenceFileInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side="top">
<p>Please enter a Confluence domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
if (isJira) {
return (
<JiraIssueSelector
value={selectedIssueId}
onChange={handleIssueChange}
domain={domain}
provider="jira"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={false}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<JiraIssueSelector
value={selectedIssueId}
onChange={handleIssueChange}
domain={domain}
provider="jira"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira issue'}
disabled={disabled || !domain}
showPreview={true}
onIssueInfoChange={setIssueInfo as (info: JiraIssueInfo | null) => void}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side="top">
<p>Please enter a Jira domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -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<DiscordServerInfo[]>([])
const [selectedServerId, setSelectedServerId] = useState(value)
const [selectedServer, setSelectedServer] = useState<DiscordServerInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-2">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled || !botToken}
>
{selectedServer ? (
<div className="flex items-center gap-2 overflow-hidden">
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className="h-4 w-4 rounded-full"
/>
) : (
<DiscordIcon className="h-4 w-4" />
)}
<span className="font-normal truncate">{selectedServer.name}</span>
</div>
) : (
<div className="flex items-center gap-2">
<DiscordIcon className="h-4 w-4" />
<span className="text-muted-foreground">{label}</span>
</div>
)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
<Command>
<CommandInput placeholder="Search servers..." />
<CommandList>
<CommandEmpty>
{isLoading ? (
<div className="flex items-center justify-center p-4">
<RefreshCw className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading servers...</span>
</div>
) : error ? (
<div className="p-4 text-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : servers.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm font-medium">No servers found</p>
<p className="text-xs text-muted-foreground">
Make sure your bot is added to at least one server
</p>
</div>
) : (
<div className="p-4 text-center">
<p className="text-sm font-medium">No matching servers</p>
</div>
)}
</CommandEmpty>
{servers.length > 0 && (
<CommandGroup>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Servers
</div>
{servers.map((server) => (
<CommandItem
key={server.id}
value={`server-${server.id}-${server.name}`}
onSelect={() => handleSelectServer(server)}
className="cursor-pointer"
>
<div className="flex items-center gap-2 overflow-hidden">
{server.icon ? (
<img
src={server.icon}
alt={server.name}
className="h-4 w-4 rounded-full"
/>
) : (
<DiscordIcon className="h-4 w-4" />
)}
<span className="font-normal truncate">{server.name}</span>
</div>
{server.id === selectedServerId && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Server preview */}
{showPreview && selectedServer && (
<div className="mt-2 rounded-md border border-muted bg-muted/10 p-2 relative">
<div className="absolute top-2 right-2">
<Button
variant="ghost"
size="icon"
className="h-5 w-5 hover:bg-muted"
onClick={handleClearSelection}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-3 pr-4">
<div className="flex-shrink-0 flex items-center justify-center h-6 w-6 bg-muted/20 rounded-full">
{selectedServer.icon ? (
<img
src={selectedServer.icon}
alt={selectedServer.name}
className="h-4 w-4 rounded-full"
/>
) : (
<DiscordIcon className="h-4 w-4" />
)}
</div>
<div className="overflow-hidden flex-1 min-w-0">
<h4 className="text-xs font-medium truncate">{selectedServer.name}</h4>
<div className="text-xs text-muted-foreground">
Server ID: {selectedServer.id}
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<string>('')
const [projectInfo, setProjectInfo] = useState<JiraProjectInfo | null>(null)
const [projectInfo, setProjectInfo] = useState<JiraProjectInfo | DiscordServerInfo | null>(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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<DiscordServerSelector
value={selectedProjectId}
onChange={(serverId: string, serverInfo?: DiscordServerInfo) => {
handleProjectChange(serverId, serverInfo)
}}
botToken={botToken}
label={subBlock.placeholder || 'Select Discord server'}
disabled={disabled || !botToken}
showPreview={true}
/>
</div>
</TooltipTrigger>
{!botToken && (
<TooltipContent side="top">
<p>Please enter a Bot Token first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}
// Default to Jira project selector
return (
<JiraProjectSelector
value={selectedProjectId}
onChange={handleProjectChange}
domain={domain}
provider="jira"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<JiraProjectSelector
value={selectedProjectId}
onChange={handleProjectChange}
domain={domain}
provider="jira"
requiredScopes={subBlock.requiredScopes || []}
serviceId={subBlock.serviceId}
label={subBlock.placeholder || 'Select Jira project'}
disabled={disabled}
showPreview={true}
onProjectInfoChange={setProjectInfo}
/>
</div>
</TooltipTrigger>
{!domain && (
<TooltipContent side="top">
<p>Please enter a Jira domain first</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -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)}
>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full text-left">
<SelectValue placeholder="Select operation" />
</SelectTrigger>
<SelectContent>

View File

@@ -0,0 +1,159 @@
import { DiscordIcon } from '@/components/icons'
import { DiscordResponse } from '@/tools/discord/types'
import { BlockConfig } from '../types'
export const DiscordBlock: BlockConfig<DiscordResponse> = {
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<string, any> = {}
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',
},
},
},
}

View File

@@ -31,22 +31,10 @@ export const ElevenLabsBlock: BlockConfig<ElevenLabsBlockResponse> = {
},
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: {

View File

@@ -79,26 +79,10 @@ export const GoogleSearchBlock: BlockConfig<GoogleSearchResponse> = {
},
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: {

View File

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

View File

@@ -31,11 +31,7 @@ export const ThinkingBlock: BlockConfig<ThinkingToolResponse> = {
],
inputs: {
thought: {
type: 'string',
required: true,
description: 'The detailed thought process or instruction for the model.',
},
thought: { type: 'string', required: true },
},
outputs: {

View File

@@ -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<string, BlockConfig> = {
clay: ClayBlock,
condition: ConditionBlock,
confluence: ConfluenceBlock,
discord: DiscordBlock,
elevenlabs: ElevenLabsBlock,
evaluator: EvaluatorBlock,
exa: ExaBlock,
@@ -80,8 +82,8 @@ export const registry: Record<string, BlockConfig> = {
// 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<string, BlockConfig> = {
starter: StarterBlock,
supabase: SupabaseBlock,
tavily: TavilyBlock,
telegram: TelegramBlock,
thinking: ThinkingBlock,
translate: TranslateBlock,
twilio_sms: TwilioSMSBlock,
@@ -106,7 +109,6 @@ export const registry: Record<string, BlockConfig> = {
whatsapp: WhatsAppBlock,
x: XBlock,
youtube: YouTubeBlock,
telegram: TelegramBlock,
}
// Helper functions to access the registry

View File

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

View File

@@ -1359,9 +1359,13 @@ export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24" height="24">
<path d="M 18.90625 7 C 18.90625 7 12.539063 7.4375 8.375 10.78125 C 8.355469 10.789063 8.332031 10.800781 8.3125 10.8125 C 7.589844 11.480469 7.046875 12.515625 6.375 14 C 5.703125 15.484375 4.992188 17.394531 4.34375 19.53125 C 3.050781 23.808594 2 29.058594 2 34 C 1.996094 34.175781 2.039063 34.347656 2.125 34.5 C 3.585938 37.066406 6.273438 38.617188 8.78125 39.59375 C 11.289063 40.570313 13.605469 40.960938 14.78125 41 C 15.113281 41.011719 15.429688 40.859375 15.625 40.59375 L 18.0625 37.21875 C 20.027344 37.683594 22.332031 38 25 38 C 27.667969 38 29.972656 37.683594 31.9375 37.21875 L 34.375 40.59375 C 34.570313 40.859375 34.886719 41.011719 35.21875 41 C 36.394531 40.960938 38.710938 40.570313 41.21875 39.59375 C 43.726563 38.617188 46.414063 37.066406 47.875 34.5 C 47.960938 34.347656 48.003906 34.175781 48 34 C 48 29.058594 46.949219 23.808594 45.65625 19.53125 C 45.007813 17.394531 44.296875 15.484375 43.625 14 C 42.953125 12.515625 42.410156 11.480469 41.6875 10.8125 C 41.667969 10.800781 41.644531 10.789063 41.625 10.78125 C 37.460938 7.4375 31.09375 7 31.09375 7 C 31.019531 6.992188 30.949219 6.992188 30.875 7 C 30.527344 7.046875 30.234375 7.273438 30.09375 7.59375 C 30.09375 7.59375 29.753906 8.339844 29.53125 9.40625 C 27.582031 9.09375 25.941406 9 25 9 C 24.058594 9 22.417969 9.09375 20.46875 9.40625 C 20.246094 8.339844 19.90625 7.59375 19.90625 7.59375 C 19.734375 7.203125 19.332031 6.964844 18.90625 7 Z M 18.28125 9.15625 C 18.355469 9.359375 18.40625 9.550781 18.46875 9.78125 C 16.214844 10.304688 13.746094 11.160156 11.4375 12.59375 C 11.074219 12.746094 10.835938 13.097656 10.824219 13.492188 C 10.816406 13.882813 11.039063 14.246094 11.390625 14.417969 C 11.746094 14.585938 12.167969 14.535156 12.46875 14.28125 C 17.101563 11.410156 22.996094 11 25 11 C 27.003906 11 32.898438 11.410156 37.53125 14.28125 C 37.832031 14.535156 38.253906 14.585938 38.609375 14.417969 C 38.960938 14.246094 39.183594 13.882813 39.175781 13.492188 C 39.164063 13.097656 38.925781 12.746094 38.5625 12.59375 C 36.253906 11.160156 33.785156 10.304688 31.53125 9.78125 C 31.59375 9.550781 31.644531 9.359375 31.71875 9.15625 C 32.859375 9.296875 37.292969 9.894531 40.3125 12.28125 C 40.507813 12.460938 41.1875 13.460938 41.8125 14.84375 C 42.4375 16.226563 43.09375 18.027344 43.71875 20.09375 C 44.9375 24.125 45.921875 29.097656 45.96875 33.65625 C 44.832031 35.496094 42.699219 36.863281 40.5 37.71875 C 38.5 38.496094 36.632813 38.84375 35.65625 38.9375 L 33.96875 36.65625 C 34.828125 36.378906 35.601563 36.078125 36.28125 35.78125 C 38.804688 34.671875 40.15625 33.5 40.15625 33.5 C 40.570313 33.128906 40.605469 32.492188 40.234375 32.078125 C 39.863281 31.664063 39.226563 31.628906 38.8125 32 C 38.8125 32 37.765625 32.957031 35.46875 33.96875 C 34.625 34.339844 33.601563 34.707031 32.4375 35.03125 C 32.167969 35 31.898438 35.078125 31.6875 35.25 C 29.824219 35.703125 27.609375 36 25 36 C 22.371094 36 20.152344 35.675781 18.28125 35.21875 C 18.070313 35.078125 17.8125 35.019531 17.5625 35.0625 C 16.394531 34.738281 15.378906 34.339844 14.53125 33.96875 C 12.234375 32.957031 11.1875 32 11.1875 32 C 10.960938 31.789063 10.648438 31.699219 10.34375 31.75 C 9.957031 31.808594 9.636719 32.085938 9.53125 32.464844 C 9.421875 32.839844 9.546875 33.246094 9.84375 33.5 C 9.84375 33.5 11.195313 34.671875 13.71875 35.78125 C 14.398438 36.078125 15.171875 36.378906 16.03125 36.65625 L 14.34375 38.9375 C 13.367188 38.84375 11.5 38.496094 9.5 37.71875 C 7.300781 36.863281 5.167969 35.496094 4.03125 33.65625 C 4.078125 29.097656 5.0625 24.125 6.28125 20.09375 C 6.90625 18.027344 7.5625 16.226563 8.1875 14.84375 C 8.8125 13.460938 9.492188 12.460938 9.6875 12.28125 C 12.707031 9.894531 17.140625 9.296875 18.28125 9.15625 Z M 18.5 21 C 15.949219 21 14 23.316406 14 26 C 14 28.683594 15.949219 31 18.5 31 C 21.050781 31 23 28.683594 23 26 C 23 23.316406 21.050781 21 18.5 21 Z M 31.5 21 C 28.949219 21 27 23.316406 27 26 C 27 28.683594 28.949219 31 31.5 31 C 34.050781 31 36 28.683594 36 26 C 36 23.316406 34.050781 21 31.5 21 Z M 18.5 23 C 19.816406 23 21 24.265625 21 26 C 21 27.734375 19.816406 29 18.5 29 C 17.183594 29 16 27.734375 16 26 C 16 24.265625 17.183594 23 18.5 23 Z M 31.5 23 C 32.816406 23 34 24.265625 34 26 C 34 27.734375 32.816406 29 31.5 29 C 30.183594 29 29 27.734375 29 26 C 29 24.265625 30.183594 23 31.5 23 Z" />
</svg>
<svg {...props} width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fillRule="nonzero">
</path>
</g>
</svg>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DiscordGetUserParams, DiscordGetUserResponse> = {
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<string, string> = {
'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}`
},
}

View File

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

View File

@@ -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<string, string> = {
'Content-Type': 'application/json',
}
if (params.botToken) {
headers['Authorization'] = `Bot ${params.botToken}`
}
return headers
},
body: (params: DiscordSendMessageParams) => {
const body: Record<string, any> = {}
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)}`
},
}

View File

@@ -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<string, any>
}
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<DiscordAuthParams, 'serverId'> {
serverId: string
}
export interface DiscordGetUserParams extends Omit<DiscordAuthParams, 'serverId'> {
userId: string
}
interface BaseDiscordResponse {
success: boolean
output: Record<string, any>
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

View File

@@ -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<string, ToolConfig> = {
@@ -152,4 +153,8 @@ export const tools: Record<string, ToolConfig> = {
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,
}

View File

@@ -261,7 +261,7 @@ function extractInputs(content: string): Record<string, any> {
const inputs: Record<string, any> = {}
// 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<string, any> {
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',