feat(webhook): added whatsapp block/tool, added scaffolding for webhooks with modal, added webhooks table and ran migrations

This commit is contained in:
Waleed Latif
2025-03-07 13:22:12 -08:00
parent c2406c4eb6
commit cee341ff80
20 changed files with 1881 additions and 11 deletions

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
export const dynamic = 'force-dynamic'
// Get a specific webhook
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.id, params.id), eq(workflow.userId, session.user.id)))
.limit(1)
if (webhooks.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
return NextResponse.json({ webhook: webhooks[0] }, { status: 200 })
} catch (error) {
console.error('Error fetching webhook:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Update a webhook
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { path, secret, provider, isActive } = body
// Find the webhook and check ownership
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, params.id))
.limit(1)
if (webhooks.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
if (webhooks[0].workflow.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Update the webhook
const updatedWebhook = await db
.update(webhook)
.set({
path: path !== undefined ? path : webhooks[0].webhook.path,
secret: secret !== undefined ? secret : webhooks[0].webhook.secret,
provider: provider !== undefined ? provider : webhooks[0].webhook.provider,
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
updatedAt: new Date(),
})
.where(eq(webhook.id, params.id))
.returning()
return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 })
} catch (error) {
console.error('Error updating webhook:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Delete a webhook
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Find the webhook and check ownership
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
userId: workflow.userId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, params.id))
.limit(1)
if (webhooks.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
if (webhooks[0].workflow.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Delete the webhook
await db.delete(webhook).where(eq(webhook.id, params.id))
return new NextResponse(null, { status: 204 })
} catch (error) {
console.error('Error deleting webhook:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Find the webhook and check ownership
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
userId: workflow.userId,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, params.id))
.limit(1)
if (webhooks.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
if (webhooks[0].workflow.userId !== session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const foundWebhook = webhooks[0].webhook
// Create a test payload based on the webhook provider
let testPayload = {}
switch (foundWebhook.provider) {
case 'whatsapp':
testPayload = {
entry: [
{
changes: [
{
value: {
metadata: {
phone_number_id: '123456789',
},
messages: [
{
from: '9876543210',
id: 'test-message-id',
timestamp: new Date().toISOString(),
text: {
body: 'This is a test message from the webhook test endpoint',
},
},
],
},
},
],
},
],
}
break
case 'github':
testPayload = {
action: 'test',
repository: {
full_name: 'user/repo',
},
sender: {
login: 'testuser',
},
}
break
case 'stripe':
testPayload = {
id: 'evt_test',
type: 'test.webhook',
created: Math.floor(Date.now() / 1000),
data: {
object: {
id: 'test_obj_123',
},
},
}
break
default:
testPayload = {
event: 'test',
timestamp: new Date().toISOString(),
data: {
message: 'This is a test webhook event',
},
}
}
// Make a request to the webhook trigger endpoint
const baseUrl = new URL(request.url).origin
const webhookPath = foundWebhook.path.startsWith('/')
? foundWebhook.path
: `/${foundWebhook.path}`
const triggerUrl = `${baseUrl}/api/webhooks/trigger${webhookPath}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (foundWebhook.secret) {
headers['Authorization'] = `Bearer ${foundWebhook.secret}`
}
try {
const response = await fetch(triggerUrl, {
method: 'POST',
headers,
body: JSON.stringify(testPayload),
})
const responseData = await response.json().catch(() => ({}))
return NextResponse.json({
success: response.ok,
status: response.status,
statusText: response.statusText,
data: responseData,
})
} catch (error: any) {
return NextResponse.json({
success: false,
error: error.message || 'Failed to trigger webhook',
})
}
} catch (error: any) {
console.error('Error testing webhook:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

91
app/api/webhooks/route.ts Normal file
View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getSession } from '@/lib/auth'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
export const dynamic = 'force-dynamic'
// Get all webhooks for the current user
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(workflow.userId, session.user.id))
return NextResponse.json({ webhooks }, { status: 200 })
} catch (error) {
console.error('Error fetching webhooks:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Create a new webhook
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workflowId, path, secret, provider } = body
// Validate input
if (!workflowId || !path) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Check if the workflow belongs to the user
const workflows = await db
.select()
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id)))
.limit(1)
if (workflows.length === 0) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Check if a webhook with the same path already exists
const existingWebhooks = await db.select().from(webhook).where(eq(webhook.path, path)).limit(1)
if (existingWebhooks.length > 0) {
return NextResponse.json({ error: 'Webhook path already exists' }, { status: 409 })
}
// Create the webhook
const newWebhook = await db
.insert(webhook)
.values({
id: nanoid(),
workflowId,
path,
secret,
provider,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
return NextResponse.json({ webhook: newWebhook[0] }, { status: 201 })
} catch (error) {
console.error('Error creating webhook:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import { SerializedWorkflow } from '@/serializer/types'
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
try {
// Get the webhook path from the URL
const url = new URL(request.url)
const path = url.pathname.replace('/api/webhooks/trigger', '')
if (!path || path === '/') {
return new NextResponse('Invalid webhook path', { status: 400 })
}
// Find the webhook in the database
const webhooks = await db
.select({
webhook: webhook,
workflow: workflow,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.path, path), eq(webhook.isActive, true), eq(workflow.isDeployed, true)))
.limit(1)
if (webhooks.length === 0) {
return new NextResponse('Webhook not found', { status: 404 })
}
const { webhook: foundWebhook, workflow: foundWorkflow } = webhooks[0]
// Verify webhook secret if provided
if (foundWebhook.secret) {
const authHeader = request.headers.get('authorization')
const providedSecret = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null
if (!providedSecret || providedSecret !== foundWebhook.secret) {
return new NextResponse('Unauthorized', { status: 401 })
}
}
// Parse the request body
const body = await request.json().catch(() => ({}))
// Create execution context with the webhook payload
const executionId = nanoid()
// Format the input to match the expected BlockOutput type
const input = {
webhook: {
data: {
path,
provider: foundWebhook.provider,
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
}
// Execute the workflow
if (foundWorkflow.state) {
const executor = new Executor(foundWorkflow.state as SerializedWorkflow, input)
const result = await executor.execute(foundWorkflow.id)
// Return the execution result
return NextResponse.json(result, { status: 200 })
}
return new NextResponse('Workflow state not found', { status: 500 })
} catch (error: any) {
console.error('Error processing webhook:', error)
return new NextResponse(`Internal Server Error: ${error.message}`, { status: 500 })
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import { SerializedWorkflow } from '@/serializer/types'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
// Handle WhatsApp webhook verification
const { searchParams } = new URL(request.url)
const mode = searchParams.get('hub.mode')
const token = searchParams.get('hub.verify_token')
const challenge = searchParams.get('hub.challenge')
// Your verification token should be stored securely (e.g., in environment variables)
const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
console.log('WhatsApp webhook verified')
return new NextResponse(challenge, { status: 200 })
}
return new NextResponse('Verification failed', { status: 403 })
}
export async function POST(request: NextRequest) {
try {
// Parse the incoming webhook payload
const body = await request.json()
// Extract the WhatsApp message data
const data = body?.entry?.[0]?.changes?.[0]?.value
if (!data) {
return new NextResponse('No data received', { status: 400 })
}
// Extract message details
const messages = data.messages || []
if (messages.length === 0) {
// This might be a different type of notification (e.g., status update)
return new NextResponse('No messages in payload', { status: 200 })
}
// Find all active WhatsApp webhooks
const webhooks = await db
.select({
webhook: webhook,
workflow: workflow,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(
and(
eq(webhook.provider, 'whatsapp'),
eq(webhook.isActive, true),
eq(workflow.isDeployed, true)
)
)
if (webhooks.length === 0) {
return new NextResponse('No active WhatsApp webhooks found', { status: 200 })
}
// Process each message
for (const message of messages) {
const phoneNumberId = data.metadata?.phone_number_id
const from = message.from
const messageId = message.id
const timestamp = message.timestamp
const text = message.text?.body
console.log(`Received WhatsApp message: ${text} from ${from}`)
// Execute each matching workflow with the WhatsApp message data
for (const { webhook: wh, workflow: wf } of webhooks) {
try {
// Get the workflow state
if (!wf.state) continue
// Create input payload for the workflow
const input = {
whatsapp: {
data: {
messageId,
from,
phoneNumberId,
text,
timestamp,
raw: message,
},
},
webhook: {
data: {
provider: 'whatsapp',
path: wh.path,
payload: body,
},
},
}
// Execute the workflow
const executor = new Executor(wf.state as SerializedWorkflow, input)
await executor.execute(wf.id)
} catch (error) {
console.error(`Error executing workflow ${wf.id}:`, error)
}
}
}
// Always return a 200 OK to WhatsApp
return new NextResponse('OK', { status: 200 })
} catch (error) {
console.error('Error processing WhatsApp webhook:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { useParams } from 'next/navigation'
import { ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { WebhookModal } from '@/components/ui/webhook-modal'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface WebhookConfigProps {
blockId: string
subBlockId?: string
isConnecting: boolean
}
export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConfigProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const params = useParams()
const workflowId = params.id as string
// Get the webhook provider from the block state
const [webhookProvider] = useSubBlockValue(blockId, 'webhookProvider')
// Generate a default path based on the workflow ID if none exists
const defaultPath = `/${workflowId.substring(0, 8)}`
// Use the default path if no path is set
const [webhookPath, setWebhookPath] = useSubBlockValue(blockId, 'webhookPath')
const handleOpenModal = () => {
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleSaveWebhook = async (path: string, secret: string) => {
try {
// Set the webhook path in the block state
if (path && path !== webhookPath) {
setWebhookPath(path)
}
// Here you would typically save the webhook to your database
// This is a placeholder for the actual API call
await saveWebhookToDatabase(workflowId, path, webhookProvider || 'generic')
return true
} catch (error) {
console.error('Error saving webhook:', error)
return false
}
}
// This function would be replaced with your actual API call
const saveWebhookToDatabase = async (workflowId: string, path: string, provider: string) => {
// Simulate an API call
return new Promise((resolve) => {
setTimeout(() => {
console.log('Webhook saved:', { workflowId, path, provider })
resolve(true)
}, 500)
})
}
return (
<div className="mt-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleOpenModal}
disabled={isConnecting}
>
<ExternalLink className="h-4 w-4 mr-2" />
View Webhook URL
</Button>
{isModalOpen && (
<WebhookModal
isOpen={isModalOpen}
onClose={handleCloseModal}
webhookPath={webhookPath || defaultPath}
webhookProvider={webhookProvider || 'generic'}
workflowId={workflowId}
onSave={handleSaveWebhook}
/>
)}
</div>
)
}

View File

@@ -14,6 +14,7 @@ import { Switch } from './components/switch'
import { Table } from './components/table'
import { TimeInput } from './components/time-input'
import { ToolInput } from './components/tool-input/tool-input'
import { WebhookConfig } from './components/webhook-config'
interface SubBlockProps {
blockId: string
@@ -59,6 +60,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
/>
</div>
)
case 'webhook-config':
return (
<WebhookConfig blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
case 'slider':
return (
<SliderInput

View File

@@ -33,20 +33,24 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
},
// Webhook configuration
{
id: 'webhookPath',
title: 'Webhook Path',
type: 'short-input',
id: 'webhookProvider',
title: 'Webhook Provider',
type: 'dropdown',
layout: 'full',
placeholder: 'Enter webhook path (e.g., /my-webhook)',
options: [
{ label: 'Generic', id: 'generic' },
{ label: 'WhatsApp', id: 'whatsapp' },
{ label: 'GitHub', id: 'github' },
{ label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',
condition: { field: 'startWorkflow', value: 'webhook' },
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
id: 'webhookConfig',
title: 'Webhook Configuration',
type: 'webhook-config',
layout: 'full',
placeholder: 'Enter a secret key for webhook security',
password: true,
condition: { field: 'startWorkflow', value: 'webhook' },
},
// Common schedule fields for all frequency types

71
blocks/blocks/whatsapp.ts Normal file
View File

@@ -0,0 +1,71 @@
import { WhatsAppIcon } from '@/components/icons'
import { ToolResponse } from '@/tools/types'
import { BlockCategory, BlockConfig, BlockIcon } from '../types'
interface WhatsAppBlockOutput extends ToolResponse {
output: {
success: boolean
messageId?: string
error?: string
}
}
export const WhatsAppBlock: BlockConfig<WhatsAppBlockOutput> = {
type: 'whatsapp',
name: 'WhatsApp',
description: 'Send WhatsApp messages',
longDescription:
'Send messages to WhatsApp users using the WhatsApp Business API. Requires WhatsApp Business API configuration.',
category: 'tools',
bgColor: '#25D366',
icon: WhatsAppIcon,
subBlocks: [
{
id: 'phoneNumber',
title: 'Recipient Phone Number',
type: 'short-input',
layout: 'full',
placeholder: 'Enter phone number with country code (e.g., +1234567890)',
},
{
id: 'message',
title: 'Message',
type: 'long-input',
layout: 'full',
placeholder: 'Enter your message',
},
{
id: 'phoneNumberId',
title: 'WhatsApp Phone Number ID',
type: 'short-input',
layout: 'full',
placeholder: 'Your WhatsApp Business Phone Number ID',
},
{
id: 'accessToken',
title: 'Access Token',
type: 'short-input',
layout: 'full',
placeholder: 'Your WhatsApp Business API Access Token',
password: true,
},
],
tools: {
access: [],
},
inputs: {
phoneNumber: { type: 'string', required: true },
message: { type: 'string', required: true },
phoneNumberId: { type: 'string', required: true },
accessToken: { type: 'string', required: true },
},
outputs: {
response: {
type: {
success: 'boolean',
messageId: 'any',
error: 'any',
},
},
},
}

View File

@@ -21,6 +21,7 @@ import { SlackBlock } from './blocks/slack'
import { StarterBlock } from './blocks/starter'
import { TavilyBlock } from './blocks/tavily'
import { TranslateBlock } from './blocks/translate'
import { WhatsAppBlock } from './blocks/whatsapp'
import { XBlock } from './blocks/x'
import { YouTubeBlock } from './blocks/youtube'
import { BlockConfig } from './types'
@@ -51,6 +52,7 @@ export {
ExaBlock,
RedditBlock,
GoogleDriveBlock,
WhatsAppBlock,
}
// Registry of all block configurations, alphabetically sorted
@@ -77,6 +79,7 @@ const blocks: Record<string, BlockConfig> = {
starter: StarterBlock,
tavily: TavilyBlock,
translate: TranslateBlock,
whatsapp: WhatsAppBlock,
x: XBlock,
youtube: YouTubeBlock,
}

View File

@@ -26,6 +26,7 @@ export type SubBlockType =
| 'date-input' // Date input
| 'time-input' // Time input
| 'oauth-input' // OAuth credential selector
| 'webhook-config' // Webhook configuration
// Component width setting
export type SubBlockLayout = 'full' | 'half'

View File

@@ -1640,7 +1640,7 @@ export function SupabaseIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function WhatsappIcon(props: SVGProps<SVGSVGElement>) {
export function WhatsAppIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}

View File

@@ -0,0 +1,191 @@
import { useState } from 'react'
import { Check, Copy, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface WebhookModalProps {
isOpen: boolean
onClose: () => void
webhookPath: string
webhookProvider: string
webhookSecret?: string
workflowId: string
onSave?: (path: string, secret: string) => void
}
export function WebhookModal({
isOpen,
onClose,
webhookPath,
webhookProvider,
webhookSecret,
workflowId,
onSave,
}: WebhookModalProps) {
const [copied, setCopied] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Format the path to ensure it starts with a slash
const formattedPath =
webhookPath && webhookPath.trim() !== ''
? webhookPath.startsWith('/')
? webhookPath
: `/${webhookPath}`
: `/${workflowId.substring(0, 8)}`
// Construct the full webhook URL
const baseUrl =
typeof window !== 'undefined'
? `${window.location.protocol}//${window.location.host}`
: 'https://your-domain.com'
const webhookUrl = `${baseUrl}/api/webhooks/trigger${formattedPath}`
const copyToClipboard = (text: string, type: string) => {
navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
const handleSave = async () => {
if (onSave) {
setSaving(true)
try {
// We're keeping the existing path and secret
await onSave(webhookPath || formattedPath.substring(1), webhookSecret || '')
onClose()
} catch (error) {
console.error('Error saving webhook configuration:', error)
} finally {
setSaving(false)
}
} else {
onClose()
}
}
// Provider-specific setup instructions
const getProviderInstructions = () => {
switch (webhookProvider) {
case 'whatsapp':
return (
<div className="space-y-2">
<h4 className="font-medium">WhatsApp Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your Meta for Developers dashboard</li>
<li>Navigate to your WhatsApp app settings</li>
<li>Under "Webhooks", click "Configure"</li>
<li>Enter the Webhook URL shown above</li>
<li>
Enter your verification token (set in environment variables as{' '}
<code>WHATSAPP_VERIFY_TOKEN</code>)
</li>
<li>Subscribe to the "messages" webhook field</li>
<li>Save your changes</li>
</ol>
<p className="text-sm text-muted-foreground mt-2">
Note: You'll need to set the WHATSAPP_VERIFY_TOKEN environment variable on your
server.
</p>
</div>
)
case 'github':
return (
<div className="space-y-2">
<h4 className="font-medium">GitHub Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your GitHub repository</li>
<li>Navigate to Settings {'>'} Webhooks</li>
<li>Click "Add webhook"</li>
<li>Enter the Webhook URL shown above</li>
<li>Set Content type to "application/json"</li>
<li>Choose which events you want to trigger the webhook</li>
<li>Ensure "Active" is checked and save</li>
</ol>
</div>
)
case 'stripe':
return (
<div className="space-y-2">
<h4 className="font-medium">Stripe Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your Stripe Dashboard</li>
<li>Navigate to Developers {'>'} Webhooks</li>
<li>Click "Add endpoint"</li>
<li>Enter the Webhook URL shown above</li>
<li>Select the events you want to listen for</li>
<li>Add the endpoint</li>
</ol>
</div>
)
default:
return (
<div className="space-y-2">
<h4 className="font-medium">Generic Webhook Setup</h4>
<p className="text-sm">Use the URL above to send webhook events to this workflow.</p>
</div>
)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Webhook Configuration</DialogTitle>
<DialogDescription>
Use this information to configure your webhook integration
{webhookProvider === 'whatsapp' && ' with whatsapp'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<div className="flex items-center space-x-2">
<Input id="webhook-url" value={webhookUrl} readOnly className="flex-1" />
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(webhookUrl, 'url')}
className="flex-shrink-0"
title="Copy URL"
>
{copied === 'url' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Provider-specific instructions */}
<div className="space-y-2 pt-2 border-t">{getProviderInstructions()}</div>
</div>
<DialogFooter className="sm:justify-between">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<div className="flex space-x-2">
<Button
variant="secondary"
onClick={() => window.open(`/api/webhooks/${workflowId}/test`, '_blank')}
>
Test Webhook
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Configuration'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,13 @@
CREATE TABLE "webhook" (
"id" text PRIMARY KEY NOT NULL,
"workflow_id" text NOT NULL,
"path" text NOT NULL,
"secret" text,
"provider" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "webhook" ADD CONSTRAINT "webhook_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "path_idx" ON "webhook" USING btree ("path");

View File

@@ -0,0 +1,805 @@
{
"id": "43c5467a-833e-4dea-9d39-68397632c0af",
"prevId": "00b2ec4a-e695-4ad1-8ca6-38ff473f869a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.environment": {
"name": "environment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"variables": {
"name": "variables",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"environment_user_id_user_id_fk": {
"name": "environment_user_id_user_id_fk",
"tableFrom": "environment",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"environment_user_id_unique": {
"name": "environment_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"general": {
"name": "general",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"settings_user_id_user_id_fk": {
"name": "settings_user_id_user_id_fk",
"tableFrom": "settings",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"settings_user_id_unique": {
"name": "settings_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.webhook": {
"name": "webhook",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"path_idx": {
"name": "path_idx",
"columns": [
{
"expression": "path",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"webhook_workflow_id_workflow_id_fk": {
"name": "webhook_workflow_id_workflow_id_fk",
"tableFrom": "webhook",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow": {
"name": "workflow",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#3972F6'"
},
"last_synced": {
"name": "last_synced",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_deployed": {
"name": "is_deployed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"deployed_at": {
"name": "deployed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"workflow_user_id_user_id_fk": {
"name": "workflow_user_id_user_id_fk",
"tableFrom": "workflow",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_logs": {
"name": "workflow_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"execution_id": {
"name": "execution_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"level": {
"name": "level",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trigger": {
"name": "trigger",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workflow_logs_workflow_id_workflow_id_fk": {
"name": "workflow_logs_workflow_id_workflow_id_fk",
"tableFrom": "workflow_logs",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_schedule": {
"name": "workflow_schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": false
},
"next_run_at": {
"name": "next_run_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_ran_at": {
"name": "last_ran_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trigger_type": {
"name": "trigger_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workflow_schedule_workflow_id_workflow_id_fk": {
"name": "workflow_schedule_workflow_id_workflow_id_fk",
"tableFrom": "workflow_schedule",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"workflow_schedule_workflow_id_unique": {
"name": "workflow_schedule_workflow_id_unique",
"nullsNotDistinct": false,
"columns": ["workflow_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -92,6 +92,13 @@
"when": 1741040211301,
"tag": "0012_minor_dexter_bennett",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1741380466542,
"tag": "0013_dusty_aaron_stack",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { boolean, json, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { boolean, json, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
@@ -121,3 +121,25 @@ export const workflowSchedule = pgTable('workflow_schedule', {
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const webhook = pgTable(
'webhook',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
path: text('path').notNull(),
secret: text('secret'),
provider: text('provider'), // e.g., "whatsapp", "github", etc.
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
// Ensure webhook paths are unique
pathIdx: uniqueIndex('path_idx').on(table.path),
}
}
)

View File

@@ -36,6 +36,7 @@ import { extractTool as tavilyExtract } from './tavily/extract'
import { searchTool as tavilySearch } from './tavily/search'
import { ToolConfig, ToolResponse } from './types'
import { executeRequest, formatRequestParams, validateToolRequest } from './utils'
import { WhatsAppTool } from './whatsapp'
import { readTool as xRead } from './x/read'
import { searchTool as xSearch } from './x/search'
import { userTool as xUser } from './x/user'
@@ -64,6 +65,7 @@ export const tools: Record<string, ToolConfig> = {
gmail_send: gmailSendTool,
gmail_read: gmailReadTool,
gmail_search: gmailSearchTool,
whatsapp: WhatsAppTool,
x_write: xWrite,
x_read: xRead,
x_search: xSearch,

78
tools/whatsapp/index.ts Normal file
View File

@@ -0,0 +1,78 @@
import { ToolConfig } from '../types'
import { WhatsAppToolResponse } from './types'
export const WhatsAppTool: ToolConfig<any, WhatsAppToolResponse> = {
id: 'whatsapp',
name: 'WhatsApp',
description: 'Send WhatsApp messages',
version: '1.0.0',
params: {
phoneNumber: {
type: 'string',
required: true,
description: 'Recipient phone number with country code',
},
message: {
type: 'string',
required: true,
description: 'Message content to send',
},
phoneNumberId: {
type: 'string',
required: true,
description: 'WhatsApp Business Phone Number ID',
},
accessToken: {
type: 'string',
required: true,
description: 'WhatsApp Business API Access Token',
},
},
request: {
url: (params) => `https://graph.facebook.com/v18.0/${params.phoneNumberId}/messages`,
method: 'POST',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => {
// Format the phone number (remove + if present)
const formattedPhoneNumber = params.phoneNumber.startsWith('+')
? params.phoneNumber.substring(1)
: params.phoneNumber
return {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: formattedPhoneNumber,
type: 'text',
text: {
body: params.message,
},
}
},
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to send WhatsApp message')
}
return {
success: true,
output: {
success: true,
messageId: data.messages?.[0]?.id,
},
error: undefined,
}
},
transformError: (error) => {
return error.message || 'Unknown error occurred'
},
}

9
tools/whatsapp/types.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ToolResponse } from '../types'
export interface WhatsAppToolResponse extends ToolResponse {
output: {
success: boolean
messageId?: string
error?: string
}
}