From cee341ff80949462c58c330124fa162e42b8212e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Mar 2025 13:22:12 -0800 Subject: [PATCH] feat(webhook): added whatsapp block/tool, added scaffolding for webhooks with modal, added webhooks table and ran migrations --- app/api/webhooks/[id]/route.ts | 132 +++ app/api/webhooks/[id]/test/route.ts | 145 ++++ app/api/webhooks/route.ts | 91 ++ app/api/webhooks/trigger/route.ts | 81 ++ app/api/webhooks/trigger/whatsapp/route.ts | 121 +++ .../sub-block/components/webhook-config.tsx | 89 ++ .../components/sub-block/sub-block.tsx | 5 + blocks/blocks/starter.ts | 22 +- blocks/blocks/whatsapp.ts | 71 ++ blocks/index.ts | 3 + blocks/types.ts | 1 + components/icons.tsx | 2 +- components/ui/webhook-modal.tsx | 191 +++++ db/migrations/0013_dusty_aaron_stack.sql | 13 + db/migrations/meta/0013_snapshot.json | 805 ++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema.ts | 24 +- tools/index.ts | 2 + tools/whatsapp/index.ts | 78 ++ tools/whatsapp/types.ts | 9 + 20 files changed, 1881 insertions(+), 11 deletions(-) create mode 100644 app/api/webhooks/[id]/route.ts create mode 100644 app/api/webhooks/[id]/test/route.ts create mode 100644 app/api/webhooks/route.ts create mode 100644 app/api/webhooks/trigger/route.ts create mode 100644 app/api/webhooks/trigger/whatsapp/route.ts create mode 100644 app/w/[id]/components/workflow-block/components/sub-block/components/webhook-config.tsx create mode 100644 blocks/blocks/whatsapp.ts create mode 100644 components/ui/webhook-modal.tsx create mode 100644 db/migrations/0013_dusty_aaron_stack.sql create mode 100644 db/migrations/meta/0013_snapshot.json create mode 100644 tools/whatsapp/index.ts create mode 100644 tools/whatsapp/types.ts diff --git a/app/api/webhooks/[id]/route.ts b/app/api/webhooks/[id]/route.ts new file mode 100644 index 000000000..b6d6ae518 --- /dev/null +++ b/app/api/webhooks/[id]/route.ts @@ -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 }) + } +} diff --git a/app/api/webhooks/[id]/test/route.ts b/app/api/webhooks/[id]/test/route.ts new file mode 100644 index 000000000..aaf5cb160 --- /dev/null +++ b/app/api/webhooks/[id]/test/route.ts @@ -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 = { + '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 }) + } +} diff --git a/app/api/webhooks/route.ts b/app/api/webhooks/route.ts new file mode 100644 index 000000000..f091244bb --- /dev/null +++ b/app/api/webhooks/route.ts @@ -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 }) + } +} diff --git a/app/api/webhooks/trigger/route.ts b/app/api/webhooks/trigger/route.ts new file mode 100644 index 000000000..11b09ef02 --- /dev/null +++ b/app/api/webhooks/trigger/route.ts @@ -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 }) + } +} diff --git a/app/api/webhooks/trigger/whatsapp/route.ts b/app/api/webhooks/trigger/whatsapp/route.ts new file mode 100644 index 000000000..bfa1a717d --- /dev/null +++ b/app/api/webhooks/trigger/whatsapp/route.ts @@ -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 }) + } +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/components/webhook-config.tsx b/app/w/[id]/components/workflow-block/components/sub-block/components/webhook-config.tsx new file mode 100644 index 000000000..63e9ea07a --- /dev/null +++ b/app/w/[id]/components/workflow-block/components/sub-block/components/webhook-config.tsx @@ -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 ( +
+ + + {isModalOpen && ( + + )} +
+ ) +} diff --git a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index 92c8602c4..40bbd5f4d 100644 --- a/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -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) { /> ) + case 'webhook-config': + return ( + + ) case 'slider': return ( = { }, // 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 diff --git a/blocks/blocks/whatsapp.ts b/blocks/blocks/whatsapp.ts new file mode 100644 index 000000000..0f6027681 --- /dev/null +++ b/blocks/blocks/whatsapp.ts @@ -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 = { + 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', + }, + }, + }, +} diff --git a/blocks/index.ts b/blocks/index.ts index f78632219..b4f1cb63d 100644 --- a/blocks/index.ts +++ b/blocks/index.ts @@ -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 = { starter: StarterBlock, tavily: TavilyBlock, translate: TranslateBlock, + whatsapp: WhatsAppBlock, x: XBlock, youtube: YouTubeBlock, } diff --git a/blocks/types.ts b/blocks/types.ts index f5d4e5c74..273ee5d4e 100644 --- a/blocks/types.ts +++ b/blocks/types.ts @@ -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' diff --git a/components/icons.tsx b/components/icons.tsx index aee04cfd3..3ac87a25d 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -1640,7 +1640,7 @@ export function SupabaseIcon(props: SVGProps) { ) } -export function WhatsappIcon(props: SVGProps) { +export function WhatsAppIcon(props: SVGProps) { return ( 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(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 ( +
+

WhatsApp Setup Instructions

+
    +
  1. Go to your Meta for Developers dashboard
  2. +
  3. Navigate to your WhatsApp app settings
  4. +
  5. Under "Webhooks", click "Configure"
  6. +
  7. Enter the Webhook URL shown above
  8. +
  9. + Enter your verification token (set in environment variables as{' '} + WHATSAPP_VERIFY_TOKEN) +
  10. +
  11. Subscribe to the "messages" webhook field
  12. +
  13. Save your changes
  14. +
+

+ Note: You'll need to set the WHATSAPP_VERIFY_TOKEN environment variable on your + server. +

+
+ ) + case 'github': + return ( +
+

GitHub Setup Instructions

+
    +
  1. Go to your GitHub repository
  2. +
  3. Navigate to Settings {'>'} Webhooks
  4. +
  5. Click "Add webhook"
  6. +
  7. Enter the Webhook URL shown above
  8. +
  9. Set Content type to "application/json"
  10. +
  11. Choose which events you want to trigger the webhook
  12. +
  13. Ensure "Active" is checked and save
  14. +
+
+ ) + case 'stripe': + return ( +
+

Stripe Setup Instructions

+
    +
  1. Go to your Stripe Dashboard
  2. +
  3. Navigate to Developers {'>'} Webhooks
  4. +
  5. Click "Add endpoint"
  6. +
  7. Enter the Webhook URL shown above
  8. +
  9. Select the events you want to listen for
  10. +
  11. Add the endpoint
  12. +
+
+ ) + default: + return ( +
+

Generic Webhook Setup

+

Use the URL above to send webhook events to this workflow.

+
+ ) + } + } + + return ( + !open && onClose()}> + + + Webhook Configuration + + Use this information to configure your webhook integration + {webhookProvider === 'whatsapp' && ' with whatsapp'}. + + + +
+
+ +
+ + +
+
+ + {/* Provider-specific instructions */} +
{getProviderInstructions()}
+
+ + + +
+ + +
+
+
+
+ ) +} diff --git a/db/migrations/0013_dusty_aaron_stack.sql b/db/migrations/0013_dusty_aaron_stack.sql new file mode 100644 index 000000000..9d48f8cd6 --- /dev/null +++ b/db/migrations/0013_dusty_aaron_stack.sql @@ -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"); \ No newline at end of file diff --git a/db/migrations/meta/0013_snapshot.json b/db/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..727dc9ece --- /dev/null +++ b/db/migrations/meta/0013_snapshot.json @@ -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": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index ac737507f..ce29bbf0b 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/db/schema.ts b/db/schema.ts index 0e7645866..4309d9413 100644 --- a/db/schema.ts +++ b/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), + } + } +) diff --git a/tools/index.ts b/tools/index.ts index 6f1272b87..45500b994 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -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 = { gmail_send: gmailSendTool, gmail_read: gmailReadTool, gmail_search: gmailSearchTool, + whatsapp: WhatsAppTool, x_write: xWrite, x_read: xRead, x_search: xSearch, diff --git a/tools/whatsapp/index.ts b/tools/whatsapp/index.ts new file mode 100644 index 000000000..12c2a828e --- /dev/null +++ b/tools/whatsapp/index.ts @@ -0,0 +1,78 @@ +import { ToolConfig } from '../types' +import { WhatsAppToolResponse } from './types' + +export const WhatsAppTool: ToolConfig = { + 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' + }, +} diff --git a/tools/whatsapp/types.ts b/tools/whatsapp/types.ts new file mode 100644 index 000000000..d71cd6311 --- /dev/null +++ b/tools/whatsapp/types.ts @@ -0,0 +1,9 @@ +import { ToolResponse } from '../types' + +export interface WhatsAppToolResponse extends ToolResponse { + output: { + success: boolean + messageId?: string + error?: string + } +}