mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-14 09:27:58 -05:00
feat(webhook): added whatsapp block/tool, added scaffolding for webhooks with modal, added webhooks table and ran migrations
This commit is contained in:
132
app/api/webhooks/[id]/route.ts
Normal file
132
app/api/webhooks/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
145
app/api/webhooks/[id]/test/route.ts
Normal file
145
app/api/webhooks/[id]/test/route.ts
Normal 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
91
app/api/webhooks/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
81
app/api/webhooks/trigger/route.ts
Normal file
81
app/api/webhooks/trigger/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
121
app/api/webhooks/trigger/whatsapp/route.ts
Normal file
121
app/api/webhooks/trigger/whatsapp/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
71
blocks/blocks/whatsapp.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
191
components/ui/webhook-modal.tsx
Normal file
191
components/ui/webhook-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
db/migrations/0013_dusty_aaron_stack.sql
Normal file
13
db/migrations/0013_dusty_aaron_stack.sql
Normal 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");
|
||||
805
db/migrations/meta/0013_snapshot.json
Normal file
805
db/migrations/meta/0013_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
24
db/schema.ts
24
db/schema.ts
@@ -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),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
78
tools/whatsapp/index.ts
Normal 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
9
tools/whatsapp/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ToolResponse } from '../types'
|
||||
|
||||
export interface WhatsAppToolResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user