diff --git a/README.md b/README.md index 38caae24..06cfe396 100644 --- a/README.md +++ b/README.md @@ -139,18 +139,27 @@ Slack (events/messages) └─▶ POST /api/coding-agent/callback ← Trigger.dev callback URL └─▶ Posts result back to Slack thread +WhatsApp (messages) + └─▶ GET /api/coding-agent/whatsapp ← Meta hub.challenge verification handshake + └─▶ POST /api/coding-agent/whatsapp ← Incoming messages (X-Hub-Signature-256 verified) + └─▶ Chat SDK (WhatsAppAdapter) + └─▶ Handler dispatches Trigger.dev task + └─▶ POST /api/coding-agent/callback ← Trigger.dev callback URL + └─▶ Posts result back to WhatsApp thread + GitHub (webhooks) └─▶ PR events, repo operations └─▶ lib/github/* helpers (file trees, submodules, repo management) ``` - **Slack adapter** — Receives messages via Slack's Events API, processes them through Chat SDK handlers, and triggers coding agent tasks via Trigger.dev. Results are posted back to the originating Slack thread. +- **WhatsApp adapter** — Receives messages via the WhatsApp Business Cloud API. Uses the same coding agent handlers as Slack. Verification handshake is handled automatically on GET. - **GitHub integration** — Manages repo operations (file trees, submodules, PRs) used by the coding agent to create and update pull requests. - **State** — Thread state (status, run IDs, PRs) is stored in Redis via the Chat SDK's ioredis state adapter. ### Updating Testing/Dev URLs -When deploying to a new environment (e.g. preview branches, local dev via ngrok), you need to update callback URLs in two places: +When deploying to a new environment (e.g. preview branches, local dev via ngrok), you need to update callback URLs in the relevant places: #### Slack — Subscription Events & Interactivity @@ -165,6 +174,24 @@ When deploying to a new environment (e.g. preview branches, local dev via ngrok) ``` 4. Slack will send a `url_verification` challenge — the route handles this automatically. +#### WhatsApp — Meta App Webhook + +1. Go to [developers.facebook.com/apps](https://developers.facebook.com/apps) → select your app +2. **WhatsApp > Configuration** → Update the Callback URL to: + ``` + https:///api/coding-agent/whatsapp + ``` +3. Set the **Verify Token** to match your `WHATSAPP_VERIFY_TOKEN` env var +4. Subscribe to the `messages` webhook field + +Required environment variables: +``` +WHATSAPP_ACCESS_TOKEN=... # Meta access token (permanent or system user token) +WHATSAPP_APP_SECRET=... # App secret for X-Hub-Signature-256 verification +WHATSAPP_PHONE_NUMBER_ID=... # Bot's phone number ID from Meta dashboard +WHATSAPP_VERIFY_TOKEN=... # User-defined secret for webhook verification +``` + #### Trigger.dev — Callback URL The coding agent task calls back to the API when it finishes. Update the callback URL environment variable so it points to your current deployment: diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index 966c542c..207f464e 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -3,11 +3,38 @@ import { after } from "next/server"; import { codingAgentBot } from "@/lib/coding-agent/bot"; import "@/lib/coding-agent/handlers/registerHandlers"; +/** + * GET /api/coding-agent/[platform] + * + * Handles webhook verification handshakes for platforms that use GET-based challenges. + * Currently handles WhatsApp's hub.challenge verification. + * + * @param request - The incoming webhook verification request + * @param params.params + * @param params - Route params containing the platform name + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform } = await params; + + await codingAgentBot.initialize(); + + const handler = codingAgentBot.webhooks[platform as keyof typeof codingAgentBot.webhooks]; + + if (!handler) { + return new Response("Unknown platform", { status: 404 }); + } + + return handler(request, { waitUntil: p => after(() => p) }); +} + /** * POST /api/coding-agent/[platform] * * Webhook endpoint for the coding agent bot. - * Currently handles Slack webhooks via dynamic [platform] segment. + * Handles Slack, WhatsApp, and GitHub webhook events via the dynamic [platform] segment. * * @param request - The incoming webhook request * @param params.params diff --git a/app/api/coding-agent/__tests__/route.test.ts b/app/api/coding-agent/__tests__/route.test.ts index f32b6d39..aa511bd5 100644 --- a/app/api/coding-agent/__tests__/route.test.ts +++ b/app/api/coding-agent/__tests__/route.test.ts @@ -11,19 +11,21 @@ vi.mock("next/server", async () => { const mockInitialize = vi.fn(); const mockSlackWebhook = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); +const mockWhatsAppWebhook = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); vi.mock("@/lib/coding-agent/bot", () => ({ codingAgentBot: { initialize: mockInitialize, webhooks: { slack: mockSlackWebhook, + whatsapp: mockWhatsAppWebhook, }, }, })); vi.mock("@/lib/coding-agent/handlers/registerHandlers", () => ({})); -const { POST } = await import("../[platform]/route"); +const { GET, POST } = await import("../[platform]/route"); describe("POST /api/coding-agent/[platform]", () => { beforeEach(() => { @@ -113,6 +115,26 @@ describe("POST /api/coding-agent/[platform]", () => { expect(callOrder).toEqual(["initialize", "webhook"]); }); + it("delegates WhatsApp POST events to bot webhook handler", async () => { + const body = JSON.stringify({ + object: "whatsapp_business_account", + entry: [{ changes: [{ value: { messages: [] }, field: "messages" }] }], + }); + + const request = new NextRequest("https://example.com/api/coding-agent/whatsapp", { + method: "POST", + body, + headers: { "content-type": "application/json", "x-hub-signature-256": "sha256=test" }, + }); + + const response = await POST(request, { + params: Promise.resolve({ platform: "whatsapp" }), + }); + + expect(response.status).toBe(200); + expect(mockWhatsAppWebhook).toHaveBeenCalled(); + }); + it("does not call initialize for url_verification challenges", async () => { const body = JSON.stringify({ type: "url_verification", @@ -132,3 +154,32 @@ describe("POST /api/coding-agent/[platform]", () => { expect(mockInitialize).not.toHaveBeenCalled(); }); }); + +describe("GET /api/coding-agent/[platform]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates WhatsApp GET verification to bot webhook handler", async () => { + const request = new NextRequest( + "https://example.com/api/coding-agent/whatsapp?hub.mode=subscribe&hub.verify_token=test&hub.challenge=abc123", + ); + + const response = await GET(request, { + params: Promise.resolve({ platform: "whatsapp" }), + }); + + expect(response.status).toBe(200); + expect(mockWhatsAppWebhook).toHaveBeenCalled(); + }); + + it("returns 404 for unknown platforms on GET", async () => { + const request = new NextRequest("https://example.com/api/coding-agent/unknown"); + + const response = await GET(request, { + params: Promise.resolve({ platform: "unknown" }), + }); + + expect(response.status).toBe(404); + }); +}); diff --git a/lib/coding-agent/bot.ts b/lib/coding-agent/bot.ts index 4cd2db31..1b972fa3 100644 --- a/lib/coding-agent/bot.ts +++ b/lib/coding-agent/bot.ts @@ -1,6 +1,7 @@ import { Chat, ConsoleLogger } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; import { createGitHubAdapter } from "@chat-adapter/github"; +import { createWhatsAppAdapter } from "@chat-adapter/whatsapp"; import { createIoRedisState } from "@chat-adapter/state-ioredis"; import redis from "@/lib/redis/connection"; import type { CodingAgentThreadState } from "./types"; @@ -9,7 +10,7 @@ import { validateCodingAgentEnv } from "./validateEnv"; const logger = new ConsoleLogger(); /** - * Creates a new Chat bot instance configured with the Slack adapter. + * Creates a new Chat bot instance configured with Slack, GitHub, and WhatsApp adapters. */ export function createCodingAgentBot() { validateCodingAgentEnv(); @@ -40,9 +41,18 @@ export function createCodingAgentBot() { logger, }); - return new Chat<{ slack: SlackAdapter; github: ReturnType }, CodingAgentThreadState>({ + const whatsapp = createWhatsAppAdapter({ logger }); + + return new Chat< + { + slack: SlackAdapter; + github: ReturnType; + whatsapp: ReturnType; + }, + CodingAgentThreadState + >({ userName: "Recoup Agent", - adapters: { slack, github }, + adapters: { slack, github, whatsapp }, state, }); } diff --git a/lib/coding-agent/validateEnv.ts b/lib/coding-agent/validateEnv.ts index 425a32d7..9c8a6eb2 100644 --- a/lib/coding-agent/validateEnv.ts +++ b/lib/coding-agent/validateEnv.ts @@ -5,6 +5,10 @@ const REQUIRED_ENV_VARS = [ "GITHUB_WEBHOOK_SECRET", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", + "WHATSAPP_ACCESS_TOKEN", + "WHATSAPP_APP_SECRET", + "WHATSAPP_PHONE_NUMBER_ID", + "WHATSAPP_VERIFY_TOKEN", ] as const; /**