diff --git a/example.env.local b/.env.local.example similarity index 89% rename from example.env.local rename to .env.local.example index 0fb0142c0..5e24c82c2 100644 --- a/example.env.local +++ b/.env.local.example @@ -552,3 +552,60 @@ ELIZA_APP_DISCORD_CLIENT_SECRET= # Leader election key for Discord bot (for distributed deployments) # ELIZA_APP_LEADER_KEY=discord:eliza-app-bot:leader:local + +# ============================================================================= +# ELIZA APP - WHATSAPP (Optional) +# ============================================================================= +# WhatsApp Business Cloud API integration for the Eliza App public bot. +# Requires a Meta Business account and WhatsApp Business App. +# Setup guide: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started + +# Permanent access token from Meta Business Settings > System Users +# Generate with whatsapp_business_messaging permission +# For testing, use the temporary token from Meta App Dashboard > WhatsApp > API Setup +ELIZA_APP_WHATSAPP_ACCESS_TOKEN= + +# Phone Number ID from Meta App Dashboard > WhatsApp > API Setup +# This is NOT the phone number itself — it's Meta's internal ID for your number +ELIZA_APP_WHATSAPP_PHONE_NUMBER_ID= + +# App Secret from Meta App Dashboard > Settings > Basic +# Used for HMAC-SHA256 webhook signature verification (X-Hub-Signature-256) +ELIZA_APP_WHATSAPP_APP_SECRET= + +# Custom verify token for webhook handshake (you choose this value) +# Must match what you enter in Meta App Dashboard > WhatsApp > Configuration > Callback URL +# Generate with: openssl rand -hex 32 +ELIZA_APP_WHATSAPP_VERIFY_TOKEN= + +# Display phone number in E.164 format (e.g. +15551649988) +# Shown to users on the frontend so they know which number to message +ELIZA_APP_WHATSAPP_PHONE_NUMBER= + +# ============================================================================= +# ORGANIZATION-LEVEL WHATSAPP (Dashboard Connections) +# ============================================================================= +# Per-organization WhatsApp Business integration via the dashboard. +# Credentials are stored in the database (secrets service) — NO env vars needed. +# +# How it works: +# 1. Go to Dashboard > Settings > Connections +# 2. Find "WhatsApp Business" card and enter your Meta credentials +# 3. After connecting, copy the Webhook URL and Verify Token +# 4. Configure the webhook in Meta App Dashboard > WhatsApp > Configuration +# +# For local development, expose your server via ngrok: +# ELIZA_API_URL=https://your-ngrok-url.ngrok.io +# +# Dev-only fallback env vars (used when org has no stored credentials): +# WHATSAPP_ACCESS_TOKEN= +# WHATSAPP_PHONE_NUMBER_ID= +# WHATSAPP_APP_SECRET= + +# ============================================================================= +# WEBHOOK DEVELOPMENT (Optional) +# ============================================================================= +# Skip webhook signature verification in non-production environments. +# Useful for testing WhatsApp/Blooio/Telegram webhooks with curl. +# NEVER set this in production — it is ignored even if set. +# SKIP_WEBHOOK_VERIFICATION=true diff --git a/README.md b/README.md index 5e0796e8d..9dfb8b0ec 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ OPENAI_API_KEY=sk-your_openai_key AI_GATEWAY_API_KEY=your_gateway_key ``` -**Eliza App variables** (for Telegram, iMessage, and Discord integrations): +**Eliza App variables** (for Telegram, iMessage, Discord, and WhatsApp integrations): ```env # JWT secret for Eliza App user sessions (required) @@ -441,8 +441,19 @@ ELIZA_APP_BLOOIO_API_KEY= # From Blooio dashboard ELIZA_APP_DISCORD_BOT_TOKEN= # Developer Portal → Bot ELIZA_APP_DISCORD_APPLICATION_ID= # Developer Portal → General Information (also the OAuth2 Client ID) ELIZA_APP_DISCORD_CLIENT_SECRET= # Developer Portal → OAuth2 → Client Secret + +# WhatsApp Business Cloud API (optional — for the public Eliza App bot) +ELIZA_APP_WHATSAPP_ACCESS_TOKEN= # Meta Business Settings → System Users → Generate Token +ELIZA_APP_WHATSAPP_PHONE_NUMBER_ID= # Meta App Dashboard → WhatsApp → API Setup +ELIZA_APP_WHATSAPP_APP_SECRET= # Meta App Dashboard → Settings → Basic → App Secret +ELIZA_APP_WHATSAPP_VERIFY_TOKEN= # Generate: openssl rand -hex 32 +ELIZA_APP_WHATSAPP_PHONE_NUMBER= # Display phone number in E.164 format (e.g. +14245074963) ``` +**Organization-level WhatsApp** (Dashboard > Settings > Connections): + +> Per-organization WhatsApp credentials are stored in the database via the dashboard UI — **no env vars required**. Each organization connects their own WhatsApp Business account by entering their Access Token, Phone Number ID, and App Secret in the connections settings. The webhook URL and verify token are auto-generated and displayed after connecting. + See [example.env.local](example.env.local) for the full list of Eliza App environment variables. **Generate secure passwords:** diff --git a/app/api/eliza-app/auth/discord/route.ts b/app/api/eliza-app/auth/discord/route.ts index 8682f6811..9e211c31e 100644 --- a/app/api/eliza-app/auth/discord/route.ts +++ b/app/api/eliza-app/auth/discord/route.ts @@ -299,6 +299,7 @@ async function handleDiscordAuth( discordId: discordUser.id, ...(user.phone_number && { phoneNumber: user.phone_number }), ...(user.telegram_id && { telegramId: user.telegram_id }), + ...(user.whatsapp_id && { whatsappId: user.whatsapp_id }), }, ); diff --git a/app/api/eliza-app/auth/telegram/route.ts b/app/api/eliza-app/auth/telegram/route.ts index 868b3ea6a..33b32be6f 100644 --- a/app/api/eliza-app/auth/telegram/route.ts +++ b/app/api/eliza-app/auth/telegram/route.ts @@ -289,6 +289,7 @@ async function handleTelegramAuth( telegramId: String(authData.id), phoneNumber: user.phone_number || phoneNumber, ...(user.discord_id && { discordId: user.discord_id }), + ...(user.whatsapp_id && { whatsappId: user.whatsapp_id }), }, ); diff --git a/app/api/eliza-app/auth/whatsapp/route.ts b/app/api/eliza-app/auth/whatsapp/route.ts new file mode 100644 index 000000000..858ca3a9d --- /dev/null +++ b/app/api/eliza-app/auth/whatsapp/route.ts @@ -0,0 +1,220 @@ +/** + * Eliza App - WhatsApp Authentication Endpoint + * + * Issues JWT session tokens for users already identified via WhatsApp webhook. + * Since WhatsApp users are auto-provisioned on first message, this endpoint + * allows the eliza-app frontend to authenticate them using their WhatsApp ID. + * + * Flow: + * 1. User messages the WhatsApp bot → auto-provisioned with whatsapp_id + * 2. Frontend redirects user with whatsapp_id claim + * 3. This endpoint verifies the user exists and issues a session token + * + * POST /api/eliza-app/auth/whatsapp + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; +import { + elizaAppUserService, + elizaAppSessionService, + type ValidatedSession, +} from "@/lib/services/eliza-app"; + +/** + * Request body schema + */ +const whatsappAuthSchema = z.object({ + // WhatsApp ID (digits only, e.g. "14245074963") + whatsapp_id: z.string() + .min(7, "WhatsApp ID must be at least 7 digits") + .max(15, "WhatsApp ID must be at most 15 digits") + .regex(/^\d+$/, "WhatsApp ID must contain only digits"), +}); + +/** + * Success response type + */ +interface AuthSuccessResponse { + success: true; + user: { + id: string; + whatsapp_id: string; + whatsapp_name: string | null; + phone_number: string | null; + name: string | null; + organization_id: string; + }; + session: { + token: string; + expires_at: string; + }; +} + +/** + * Error response type + */ +interface AuthErrorResponse { + success: false; + error: string; + code: string; +} + +async function handleWhatsAppAuth( + request: NextRequest, +): Promise> { + // Parse and validate request body + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { success: false, error: "Invalid JSON body", code: "INVALID_JSON" }, + { status: 400 }, + ); + } + + const parseResult = whatsappAuthSchema.safeParse(body); + if (!parseResult.success) { + return NextResponse.json( + { success: false, error: "Invalid request body", code: "INVALID_REQUEST" }, + { status: 400 }, + ); + } + + const { whatsapp_id: whatsappId } = parseResult.data; + + // Check for existing session (session-based linking) + const authHeader = request.headers.get("authorization"); + let existingSession: ValidatedSession | null = null; + if (authHeader) { + existingSession = await elizaAppSessionService.validateAuthHeader(authHeader); + if (existingSession) { + logger.info("[ElizaApp WhatsAppAuth] Session-based linking detected", { + existingUserId: existingSession.userId, + }); + } + } + + if (existingSession) { + // ---- SESSION-BASED LINKING: Link WhatsApp to existing user ---- + const linkResult = await elizaAppUserService.linkWhatsAppToUser( + existingSession.userId, + { whatsappId }, + ); + + if (!linkResult.success) { + return NextResponse.json( + { + success: false, + error: linkResult.error || "This WhatsApp account is already linked to another account", + code: "WHATSAPP_ALREADY_LINKED", + }, + { status: 409 }, + ); + } + + // Fetch the updated user + const updatedUser = await elizaAppUserService.getById(existingSession.userId); + if (!updatedUser || !updatedUser.organization) { + return NextResponse.json( + { success: false, error: "User not found after linking", code: "INTERNAL_ERROR" }, + { status: 500 }, + ); + } + + const session = await elizaAppSessionService.createSession( + updatedUser.id, + updatedUser.organization.id, + { + whatsappId, + phoneNumber: updatedUser.phone_number || undefined, + ...(updatedUser.telegram_id && { telegramId: updatedUser.telegram_id }), + ...(updatedUser.discord_id && { discordId: updatedUser.discord_id }), + }, + ); + + logger.info("[ElizaApp WhatsAppAuth] Session-based WhatsApp linking successful", { + userId: updatedUser.id, + whatsappId, + }); + + return NextResponse.json({ + success: true, + user: { + id: updatedUser.id, + whatsapp_id: updatedUser.whatsapp_id!, + whatsapp_name: updatedUser.whatsapp_name, + phone_number: updatedUser.phone_number, + name: updatedUser.name, + organization_id: updatedUser.organization.id, + }, + session: { + token: session.token, + expires_at: session.expiresAt.toISOString(), + }, + }); + } + + // ---- STANDARD FLOW: Look up existing user by WhatsApp ID ---- + // WhatsApp users must first message the bot to be auto-provisioned + const userWithOrg = await elizaAppUserService.getByWhatsAppId(whatsappId); + + if (!userWithOrg || !userWithOrg.organization) { + return NextResponse.json( + { + success: false, + error: "WhatsApp account not found. Please message our WhatsApp bot first to create your account.", + code: "USER_NOT_FOUND", + }, + { status: 404 }, + ); + } + + logger.info("[ElizaApp WhatsAppAuth] Authentication successful", { + userId: userWithOrg.id, + whatsappId, + }); + + // Create session + const session = await elizaAppSessionService.createSession( + userWithOrg.id, + userWithOrg.organization.id, + { + whatsappId, + phoneNumber: userWithOrg.phone_number || undefined, + ...(userWithOrg.telegram_id && { telegramId: userWithOrg.telegram_id }), + ...(userWithOrg.discord_id && { discordId: userWithOrg.discord_id }), + }, + ); + + return NextResponse.json({ + success: true, + user: { + id: userWithOrg.id, + whatsapp_id: userWithOrg.whatsapp_id!, + whatsapp_name: userWithOrg.whatsapp_name, + phone_number: userWithOrg.phone_number, + name: userWithOrg.name, + organization_id: userWithOrg.organization.id, + }, + session: { + token: session.token, + expires_at: session.expiresAt.toISOString(), + }, + }); +} + +// Export with rate limiting +export const POST = withRateLimit(handleWhatsAppAuth, RateLimitPresets.STANDARD); + +// Health check +export async function GET(): Promise { + return NextResponse.json({ + status: "ok", + service: "eliza-app-whatsapp-auth", + timestamp: new Date().toISOString(), + }); +} diff --git a/app/api/eliza-app/user/me/route.ts b/app/api/eliza-app/user/me/route.ts index fd95c16dd..38b9a1012 100644 --- a/app/api/eliza-app/user/me/route.ts +++ b/app/api/eliza-app/user/me/route.ts @@ -26,6 +26,8 @@ interface UserInfoResponse { discord_username: string | null; discord_global_name: string | null; discord_avatar_url: string | null; + whatsapp_id: string | null; + whatsapp_name: string | null; phone_number: string | null; name: string | null; avatar: string | null; @@ -106,6 +108,8 @@ async function handleGetUser( discord_username: user.discord_username, discord_global_name: user.discord_global_name, discord_avatar_url: user.discord_avatar_url, + whatsapp_id: user.whatsapp_id, + whatsapp_name: user.whatsapp_name, phone_number: user.phone_number, name: user.name, avatar: user.avatar, diff --git a/app/api/eliza-app/webhook/whatsapp/route.ts b/app/api/eliza-app/webhook/whatsapp/route.ts new file mode 100644 index 000000000..6f99915d3 --- /dev/null +++ b/app/api/eliza-app/webhook/whatsapp/route.ts @@ -0,0 +1,354 @@ +/** + * Eliza App - Public WhatsApp Webhook + * + * Receives messages from WhatsApp Cloud API and routes them to the default Eliza agent. + * Auto-provisions users on first message based on WhatsApp ID (phone number digits). + * Uses ASSISTANT mode for full multi-step action execution. + * + * Cross-platform: Since WhatsApp ID IS a phone number, accounts are automatically + * linked with Telegram/iMessage users who have the same phone number. + * + * GET /api/eliza-app/webhook/whatsapp -- Webhook verification handshake + * POST /api/eliza-app/webhook/whatsapp -- Incoming messages + */ + +import { NextRequest, NextResponse } from "next/server"; +import { ZodError } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; +import { elizaAppUserService } from "@/lib/services/eliza-app"; +import { whatsAppAuthService } from "@/lib/services/eliza-app/whatsapp-auth"; +import type { UserWithOrganization } from "@/lib/types"; +import { roomsService } from "@/lib/services/agents/rooms"; +import { isAlreadyProcessed, markAsProcessed, removeProcessedMark } from "@/lib/utils/idempotency"; +import { generateElizaAppRoomId } from "@/lib/utils/deterministic-uuid"; +import { + parseWhatsAppWebhookPayload, + extractWhatsAppMessages, + sendWhatsAppMessage, + markWhatsAppMessageAsRead, + startWhatsAppTypingIndicator, + isValidWhatsAppId, + type WhatsAppIncomingMessage, +} from "@/lib/utils/whatsapp-api"; +import { elizaAppConfig } from "@/lib/services/eliza-app/config"; +import { runtimeFactory } from "@/lib/eliza/runtime-factory"; +import { createMessageHandler } from "@/lib/eliza/message-handler"; +import { userContextService } from "@/lib/eliza/user-context"; +import { AgentMode } from "@/lib/eliza/agent-mode-types"; +import { distributedLocks } from "@/lib/cache/distributed-locks"; +import { createPerfTrace } from "@/lib/utils/perf-trace"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; // Extended for ASSISTANT mode multi-step execution + +const { defaultAgentId: DEFAULT_AGENT_ID } = elizaAppConfig; +const { + accessToken: WA_ACCESS_TOKEN, + phoneNumberId: WA_PHONE_NUMBER_ID, +} = elizaAppConfig.whatsapp; + +async function sendWhatsAppResponse( + to: string, + text: string, +): Promise { + try { + const response = await sendWhatsAppMessage( + WA_ACCESS_TOKEN, + WA_PHONE_NUMBER_ID, + to, + text, + ); + + logger.info("[ElizaApp WhatsAppWebhook] Message sent", { + to, + messageId: response.messages?.[0]?.id, + }); + + return true; + } catch (error) { + logger.error("[ElizaApp WhatsAppWebhook] Failed to send message", { + to, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +async function handleIncomingMessage(msg: WhatsAppIncomingMessage): Promise { + const text = msg.text?.trim(); + if (!text) return true; // Only handle text messages for now + + // Validate WhatsApp ID format (digits only, 7-15 chars) before use + if (!isValidWhatsAppId(msg.from)) { + logger.warn("[ElizaApp WhatsAppWebhook] Invalid WhatsApp ID format, skipping", { + from: msg.from, + messageId: msg.messageId, + }); + return true; // Mark as processed to avoid infinite retries on bad data + } + + // Mark message as read for better UX (sends blue checkmarks). + // Uses retry with backoff since the first outbound fetch can fail on cold connections. + const markRead = async (retries = 2) => { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + await markWhatsAppMessageAsRead(WA_ACCESS_TOKEN, WA_PHONE_NUMBER_ID, msg.messageId); + return; + } catch (err) { + if (attempt < retries) { + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } else { + logger.warn("[ElizaApp WhatsAppWebhook] Failed to mark as read after retries", { + messageId: msg.messageId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + }; + markRead(); + + const perfTrace = createPerfTrace("whatsapp-webhook"); + const stopTyping = startWhatsAppTypingIndicator(WA_ACCESS_TOKEN, WA_PHONE_NUMBER_ID, msg.messageId); + + try { + perfTrace.mark("user-provisioning"); + logger.info("[ElizaApp WhatsAppWebhook] Auto-provisioning user", { + whatsappId: `***${msg.from.slice(-4)}`, + profileName: msg.profileName, + }); + + const { user: userWithOrg, organization, isNew } = + await elizaAppUserService.findOrCreateByWhatsAppId(msg.from, msg.profileName); + + logger.info("[ElizaApp WhatsAppWebhook] User provisioned", { + userId: userWithOrg.id, + organizationId: organization.id, + isNewUser: isNew, + whatsappId: `***${msg.from.slice(-4)}`, + }); + + const roomId = generateElizaAppRoomId("whatsapp", DEFAULT_AGENT_ID, msg.from); + const entityId = userWithOrg.id; + + perfTrace.mark("room-setup"); + const existingRoom = await roomsService.getRoomSummary(roomId); + if (!existingRoom) { + await roomsService.createRoom({ + id: roomId, + agentId: DEFAULT_AGENT_ID, + entityId, + source: "whatsapp", + type: "DM", + name: `WhatsApp: ${msg.profileName || msg.from}`, + metadata: { + channel: "whatsapp", + whatsappId: msg.from, + userId: entityId, + organizationId: organization.id, + }, + }); + } + try { + await roomsService.addParticipant(roomId, entityId, DEFAULT_AGENT_ID); + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + if (!errMsg.includes("already") && !errMsg.includes("duplicate") && !errMsg.includes("exists")) { + throw error; + } + } + + perfTrace.mark("acquire-lock"); + const lock = await distributedLocks.acquireRoomLockWithRetry(roomId, 120000, { + maxRetries: 10, + initialDelayMs: 100, + maxDelayMs: 2000, + }); + + if (!lock) { + logger.error("[ElizaApp WhatsAppWebhook] Failed to acquire room lock", { roomId }); + return false; + } + + try { + const user: UserWithOrganization = { ...userWithOrg, organization }; + + const userContext = await userContextService.buildContext({ + user, + isAnonymous: false, + agentMode: AgentMode.ASSISTANT, + }); + userContext.characterId = DEFAULT_AGENT_ID; + userContext.webSearchEnabled = true; + userContext.modelPreferences = elizaAppConfig.modelPreferences; + + logger.info("[ElizaApp WhatsAppWebhook] Processing message", { + userId: entityId, + roomId, + mode: "assistant", + }); + + perfTrace.mark("create-runtime"); + const runtime = await runtimeFactory.createRuntimeForUser(userContext); + const messageHandler = createMessageHandler(runtime, userContext); + + perfTrace.mark("message-processing"); + const result = await messageHandler.process({ + roomId, + text, + agentModeConfig: { mode: AgentMode.ASSISTANT }, + }); + + const responseContent = result.message.content; + const responseText = + typeof responseContent === "string" + ? responseContent + : responseContent?.text || ""; + + perfTrace.mark("send-response"); + if (responseText) { + const sent = await sendWhatsAppResponse(msg.from, responseText); + if (!sent) { + logger.warn("[ElizaApp WhatsAppWebhook] Send failed, allowing webhook retry", { + to: msg.from, + roomId, + }); + return false; + } + } + return true; + } catch (error) { + logger.error("[ElizaApp WhatsAppWebhook] Agent failed", { + error: error instanceof Error ? error.message : String(error), + roomId, + }); + return true; + } finally { + stopTyping(); + await lock.release(); + } + } finally { + stopTyping(); + perfTrace.end(); + } +} + +async function handleWhatsAppWebhookPost(request: NextRequest): Promise { + const rawBody = await request.text(); + const skipVerification = + process.env.SKIP_WEBHOOK_VERIFICATION === "true" && + process.env.NODE_ENV !== "production"; + + // Verify webhook signature (X-Hub-Signature-256) + const signatureHeader = request.headers.get("x-hub-signature-256") || ""; + + if (!skipVerification) { + const isValid = whatsAppAuthService.verifyWebhookSignature(signatureHeader, rawBody); + + if (!isValid) { + logger.warn("[ElizaApp WhatsAppWebhook] Invalid signature"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + } else { + logger.warn("[ElizaApp WhatsAppWebhook] Signature verification skipped (dev mode)"); + } + + // Parse the webhook payload + let payload; + try { + const rawPayload = JSON.parse(rawBody); + payload = parseWhatsAppWebhookPayload(rawPayload); + } catch (error) { + if (error instanceof SyntaxError) { + logger.warn("[ElizaApp WhatsAppWebhook] Invalid JSON payload"); + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + if (error instanceof ZodError) { + logger.warn("[ElizaApp WhatsAppWebhook] Invalid payload schema", { + issues: error.issues, + }); + return NextResponse.json( + { error: "Invalid payload", details: error.issues }, + { status: 400 }, + ); + } + throw error; + } + + // Extract messages from the webhook payload + const messages = extractWhatsAppMessages(payload); + + logger.info("[ElizaApp WhatsAppWebhook] Received webhook", { + messageCount: messages.length, + }); + + // Process each message + let allProcessed = true; + for (const msg of messages) { + const idempotencyKey = `whatsapp:eliza-app:${msg.messageId}`; + + if (await isAlreadyProcessed(idempotencyKey)) { + logger.info("[ElizaApp WhatsAppWebhook] Skipping duplicate", { + messageId: msg.messageId, + }); + continue; + } + + // Claim the message immediately to prevent race conditions. + // If two webhook deliveries arrive concurrently, the second one + // will see the idempotency mark and skip (preventing duplicate processing). + await markAsProcessed(idempotencyKey, "whatsapp-eliza-app"); + + const processed = await handleIncomingMessage(msg); + + if (!processed) { + // Processing failed (e.g., lock timeout, send failure) - + // remove the claim so Meta's retry can re-process the message. + await removeProcessedMark(idempotencyKey); + allProcessed = false; + } + } + + // Return 503 on lock failure to trigger webhook retry from Meta + if (!allProcessed) { + return NextResponse.json( + { success: false, error: "Service temporarily unavailable" }, + { status: 503 }, + ); + } + + return NextResponse.json({ success: true }); +} + +/** + * GET handler - Webhook verification handshake from Meta. + * + * When registering the webhook URL, Meta sends a GET request with: + * - hub.mode: "subscribe" + * - hub.verify_token: The verify token you configured + * - hub.challenge: A number to echo back + * + * Must respond with the challenge value as plain text with 200 status. + */ +async function handleWhatsAppWebhookGet(request: NextRequest): Promise { + const searchParams = request.nextUrl.searchParams; + const mode = searchParams.get("hub.mode"); + const verifyToken = searchParams.get("hub.verify_token"); + const challenge = searchParams.get("hub.challenge"); + + const result = whatsAppAuthService.verifyWebhookSubscription(mode, verifyToken, challenge); + + if (result) { + // Must return the challenge as plain text (not JSON) per Meta docs + return new NextResponse(result, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } + + return NextResponse.json({ error: "Verification failed" }, { status: 403 }); +} + +export const GET = withRateLimit(handleWhatsAppWebhookGet, RateLimitPresets.STANDARD); +export const POST = withRateLimit(handleWhatsAppWebhookPost, RateLimitPresets.AGGRESSIVE); diff --git a/app/api/v1/whatsapp/connect/route.ts b/app/api/v1/whatsapp/connect/route.ts new file mode 100644 index 000000000..8ea176d75 --- /dev/null +++ b/app/api/v1/whatsapp/connect/route.ts @@ -0,0 +1,105 @@ +/** + * WhatsApp Connect Route + * + * Stores WhatsApp Business API credentials for an organization. + * Validates the access token against Meta Graph API before storing. + * Auto-generates a verify token for webhook handshake. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { whatsappAutomationService } from "@/lib/services/whatsapp-automation"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +export async function POST(request: NextRequest): Promise { + const { user } = await requireAuthOrApiKeyWithOrg(request); + + try { + const body = await request.json(); + const { accessToken, phoneNumberId, appSecret, businessPhone } = body; + + if (!accessToken) { + return NextResponse.json( + { error: "Access token is required" }, + { status: 400 }, + ); + } + + if (!phoneNumberId) { + return NextResponse.json( + { error: "Phone Number ID is required" }, + { status: 400 }, + ); + } + + if (!appSecret) { + return NextResponse.json( + { error: "App Secret is required" }, + { status: 400 }, + ); + } + + // Validate the access token by calling Meta Graph API + const validation = await whatsappAutomationService.validateAccessToken( + accessToken, + phoneNumberId, + ); + + if (!validation.valid) { + return NextResponse.json( + { error: validation.error || "Invalid credentials" }, + { status: 400 }, + ); + } + + // Auto-generate a verify token for webhook handshake + const verifyToken = whatsappAutomationService.generateVerifyToken(); + + // Store credentials + await whatsappAutomationService.storeCredentials( + user.organization_id, + user.id, + { + accessToken, + phoneNumberId, + appSecret, + verifyToken, + businessPhone: businessPhone || validation.phoneDisplay, + }, + ); + + // Get the webhook URL to display to user + const webhookUrl = whatsappAutomationService.getWebhookUrl( + user.organization_id, + ); + + logger.info("[WhatsApp Connect] Credentials stored", { + organizationId: user.organization_id, + userId: user.id, + hasBusinessPhone: !!(businessPhone || validation.phoneDisplay), + }); + + return NextResponse.json({ + success: true, + message: "WhatsApp connected successfully", + webhookUrl, + verifyToken, + businessPhone: businessPhone || validation.phoneDisplay, + instructions: + "Configure the webhook URL and verify token in your Meta App Dashboard under WhatsApp > Configuration.", + }); + } catch (error) { + logger.error("[WhatsApp Connect] Failed to connect", { + error: error instanceof Error ? error.message : String(error), + organizationId: user.organization_id, + }); + return NextResponse.json( + { error: "Failed to connect WhatsApp" }, + { status: 500 }, + ); + } +} diff --git a/app/api/v1/whatsapp/disconnect/route.ts b/app/api/v1/whatsapp/disconnect/route.ts new file mode 100644 index 000000000..66b166712 --- /dev/null +++ b/app/api/v1/whatsapp/disconnect/route.ts @@ -0,0 +1,48 @@ +/** + * WhatsApp Disconnect Route + * + * Removes WhatsApp credentials for an organization. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { whatsappAutomationService } from "@/lib/services/whatsapp-automation"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +async function handleDisconnect(request: NextRequest): Promise { + const { user } = await requireAuthOrApiKeyWithOrg(request); + + try { + await whatsappAutomationService.removeCredentials( + user.organization_id, + user.id, + ); + + logger.info("[WhatsApp Disconnect] Credentials removed", { + organizationId: user.organization_id, + userId: user.id, + }); + + return NextResponse.json({ + success: true, + message: "WhatsApp disconnected successfully", + }); + } catch (error) { + logger.error("[WhatsApp Disconnect] Failed to disconnect", { + error: error instanceof Error ? error.message : String(error), + organizationId: user.organization_id, + }); + return NextResponse.json( + { error: "Failed to disconnect WhatsApp" }, + { status: 500 }, + ); + } +} + +// Support both POST and DELETE methods for disconnect +export const POST = handleDisconnect; +export const DELETE = handleDisconnect; diff --git a/app/api/v1/whatsapp/status/route.ts b/app/api/v1/whatsapp/status/route.ts new file mode 100644 index 000000000..204e0a247 --- /dev/null +++ b/app/api/v1/whatsapp/status/route.ts @@ -0,0 +1,45 @@ +/** + * WhatsApp Status Route + * + * Returns the current WhatsApp connection status for the organization. + */ + +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { whatsappAutomationService } from "@/lib/services/whatsapp-automation"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +export async function GET(request: NextRequest): Promise { + const { user } = await requireAuthOrApiKeyWithOrg(request); + const orgId = user.organization_id; + + try { + // Fetch status and verify token in parallel + const [status, verifyToken] = await Promise.all([ + whatsappAutomationService.getConnectionStatus(orgId), + whatsappAutomationService.getVerifyToken(orgId), + ]); + + return NextResponse.json({ + connected: status.connected, + configured: status.configured, + businessPhone: status.businessPhone, + webhookUrl: whatsappAutomationService.getWebhookUrl(orgId), + verifyToken: verifyToken || undefined, + error: status.error, + }); + } catch (error) { + logger.error("[WhatsApp Status] Failed to get status", { + error: error instanceof Error ? error.message : String(error), + orgId, + }); + return NextResponse.json( + { error: "Failed to get WhatsApp status" }, + { status: 500 }, + ); + } +} diff --git a/app/api/webhooks/whatsapp/[orgId]/route.ts b/app/api/webhooks/whatsapp/[orgId]/route.ts new file mode 100644 index 000000000..2832592ca --- /dev/null +++ b/app/api/webhooks/whatsapp/[orgId]/route.ts @@ -0,0 +1,370 @@ +/** + * Organization-Level WhatsApp Webhook Handler + * + * Receives incoming messages from WhatsApp Cloud API for a specific + * organization's WhatsApp Business account. Each organization has + * their own webhook URL with their orgId. + * + * GET /api/webhooks/whatsapp/[orgId] -- Meta verification handshake + * POST /api/webhooks/whatsapp/[orgId] -- Incoming messages + */ + +import { NextRequest, NextResponse } from "next/server"; +import { ZodError } from "zod"; +import { logger } from "@/lib/utils/logger"; +import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; +import { whatsappAutomationService } from "@/lib/services/whatsapp-automation"; +import { + parseWhatsAppWebhookPayload, + extractWhatsAppMessages, + markWhatsAppMessageAsRead, + startWhatsAppTypingIndicator, + isValidWhatsAppId, + type WhatsAppIncomingMessage, +} from "@/lib/utils/whatsapp-api"; +import { tryClaimForProcessing, releaseProcessingClaim } from "@/lib/utils/idempotency"; +import { createPerfTrace } from "@/lib/utils/perf-trace"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +interface RouteParams { + params: Promise<{ orgId: string }>; +} + +// ============================================================================ +// POST Handler - Incoming Messages +// ============================================================================ + +async function handleWhatsAppWebhook( + request: NextRequest, + context?: { params: Promise }, +): Promise { + const { orgId } = context?.params ? await context.params : { orgId: "" }; + + if (!orgId) { + return NextResponse.json( + { error: "Organization ID is required" }, + { status: 400 }, + ); + } + + try { + // Get raw body for signature verification + const rawBody = await request.text(); + + // Verify signature - only skip if explicitly disabled AND not in production + const isProduction = process.env.NODE_ENV === "production"; + const skipVerification = + process.env.SKIP_WEBHOOK_VERIFICATION === "true" && !isProduction; + + if (process.env.SKIP_WEBHOOK_VERIFICATION === "true" && isProduction) { + logger.error( + "[WhatsAppWebhook] SKIP_WEBHOOK_VERIFICATION ignored in production", + { orgId }, + ); + } + + if (skipVerification) { + logger.warn( + "[WhatsAppWebhook] Signature verification disabled (non-production)", + { orgId }, + ); + } else { + const signatureHeader = + request.headers.get("x-hub-signature-256") || ""; + + const isValid = await whatsappAutomationService.verifyWebhookSignature( + orgId, + signatureHeader, + rawBody, + ); + + if (!isValid) { + logger.warn("[WhatsAppWebhook] Invalid signature", { orgId }); + return NextResponse.json( + { error: "Invalid signature" }, + { status: 401 }, + ); + } + } + + // Parse and validate the webhook payload using Zod schema + let payload; + try { + const rawPayload = JSON.parse(rawBody); + payload = parseWhatsAppWebhookPayload(rawPayload); + } catch (parseError) { + if (parseError instanceof SyntaxError) { + logger.warn("[WhatsAppWebhook] Invalid JSON payload", { orgId }); + return NextResponse.json( + { error: "Invalid JSON" }, + { status: 400 }, + ); + } + if (parseError instanceof ZodError) { + logger.warn("[WhatsAppWebhook] Invalid payload schema", { + orgId, + issues: parseError.issues, + }); + return NextResponse.json( + { error: "Invalid payload", details: parseError.issues }, + { status: 400 }, + ); + } + throw parseError; + } + + // Extract messages from the webhook payload + const messages = extractWhatsAppMessages(payload); + + logger.info("[WhatsAppWebhook] Received webhook", { + orgId, + messageCount: messages.length, + }); + + // Process each message + for (const msg of messages) { + const idempotencyKey = `whatsapp:org:${orgId}:${msg.messageId}`; + + // Atomic claim - prevents duplicate processing across concurrent deliveries + const claimed = await tryClaimForProcessing(idempotencyKey, "whatsapp-org"); + if (!claimed) { + logger.info("[WhatsAppWebhook] Skipping duplicate", { + orgId, + messageId: msg.messageId, + }); + continue; + } + + try { + await handleIncomingMessage(orgId, msg); + } catch (error) { + logger.error("[WhatsAppWebhook] Failed to process message", { + orgId, + messageId: msg.messageId, + error: error instanceof Error ? error.message : String(error), + }); + // Release claim so the message can be retried + await releaseProcessingClaim(idempotencyKey); + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error("[WhatsAppWebhook] Error processing webhook", { + orgId, + error: error instanceof Error ? error.message : "Unknown error", + }); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// ============================================================================ +// GET Handler - Meta Verification Handshake +// ============================================================================ + +async function handleWhatsAppVerification( + request: NextRequest, + context?: { params: Promise }, +): Promise { + const { orgId } = context?.params ? await context.params : { orgId: "" }; + + if (!orgId) { + return NextResponse.json( + { error: "Organization ID is required" }, + { status: 400 }, + ); + } + + const searchParams = request.nextUrl.searchParams; + const mode = searchParams.get("hub.mode"); + const verifyToken = searchParams.get("hub.verify_token"); + const challenge = searchParams.get("hub.challenge"); + + const result = await whatsappAutomationService.verifyWebhookSubscription( + orgId, + mode, + verifyToken, + challenge, + ); + + if (result) { + logger.info("[WhatsAppWebhook] Verification handshake successful", { orgId }); + // Must return the challenge as plain text (not JSON) per Meta docs + return new NextResponse(result, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } + + logger.warn("[WhatsAppWebhook] Verification handshake failed", { orgId }); + return NextResponse.json({ error: "Verification failed" }, { status: 403 }); +} + +// ============================================================================ +// Message Handling +// ============================================================================ + +async function handleIncomingMessage( + orgId: string, + msg: WhatsAppIncomingMessage, +): Promise { + const text = msg.text?.trim(); + if (!text) { + logger.info("[WhatsAppWebhook] Skipping non-text message", { + orgId, + type: msg.type, + }); + return; + } + + // Validate WhatsApp ID format before use + if (!isValidWhatsAppId(msg.from)) { + logger.warn("[WhatsAppWebhook] Invalid WhatsApp ID format", { + orgId, + from: msg.from, + }); + return; + } + + const perfTrace = createPerfTrace("whatsapp-org-webhook"); + + perfTrace.mark("get-credentials"); + const [accessToken, phoneNumberId, businessPhone] = await Promise.all([ + whatsappAutomationService.getAccessToken(orgId), + whatsappAutomationService.getPhoneNumberId(orgId), + whatsappAutomationService.getBusinessPhone(orgId), + ]); + + // Mark message as read for better UX (sends blue checkmarks). + // Uses retry with backoff since the first outbound fetch can fail on cold connections. + if (accessToken && phoneNumberId) { + const markRead = async (retries = 2) => { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + await markWhatsAppMessageAsRead(accessToken, phoneNumberId, msg.messageId); + return; + } catch (err) { + if (attempt < retries) { + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } else { + logger.warn("[WhatsAppWebhook] Failed to mark as read after retries", { + orgId, + messageId: msg.messageId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + }; + markRead(); + } + + const stopTyping = accessToken && phoneNumberId + ? startWhatsAppTypingIndicator(accessToken, phoneNumberId, msg.messageId) + : () => {}; + + try { + logger.info("[WhatsAppWebhook] Processing incoming message", { + orgId, + from: `***${msg.from.slice(-4)}`, + hasText: !!text, + profileName: msg.profileName, + }); + + const recipient = businessPhone || msg.phoneNumberId; + + perfTrace.mark("route-message"); + const { messageRouterService } = await import( + "@/lib/services/message-router" + ); + + const messageContext = { + from: msg.from, + to: recipient, + body: text, + provider: "whatsapp" as const, + providerMessageId: msg.messageId, + messageType: "whatsapp" as const, + metadata: { + profileName: msg.profileName, + timestamp: msg.timestamp, + phoneNumberId: msg.phoneNumberId, + }, + }; + + const routeResult = + await messageRouterService.routeIncomingMessage(messageContext); + + if ( + !routeResult.success || + !routeResult.agentId || + !routeResult.organizationId + ) { + logger.info( + "[WhatsAppWebhook] Message received (agent routing not configured)", + { + orgId, + from: `***${msg.from.slice(-4)}`, + text: text.substring(0, 50), + }, + ); + return; + } + + perfTrace.mark("process-with-agent"); + const agentResponse = await messageRouterService.processWithAgent( + routeResult.agentId, + routeResult.organizationId, + { + from: msg.from, + to: recipient, + body: text, + provider: "whatsapp", + providerMessageId: msg.messageId, + messageType: "whatsapp", + }, + ); + + if (agentResponse) { + perfTrace.mark("send-response"); + const sent = await messageRouterService.sendMessage({ + to: msg.from, + from: recipient, + body: agentResponse.text, + provider: "whatsapp", + mediaUrls: agentResponse.mediaUrls, + organizationId: routeResult.organizationId, + }); + + if (sent) { + logger.info("[WhatsAppWebhook] Agent response sent", { + orgId, + to: `***${msg.from.slice(-4)}`, + }); + } else { + logger.error("[WhatsAppWebhook] Failed to send agent response", { + orgId, + to: `***${msg.from.slice(-4)}`, + }); + } + } + } finally { + stopTyping(); + perfTrace.end(); + } +} + +// Export handlers with rate limiting +export const GET = withRateLimit( + handleWhatsAppVerification, + RateLimitPresets.STANDARD, +); +export const POST = withRateLimit( + handleWhatsAppWebhook, + RateLimitPresets.AGGRESSIVE, +); diff --git a/components/settings/tabs/connections-tab.tsx b/components/settings/tabs/connections-tab.tsx index a706955a3..b75f638e1 100644 --- a/components/settings/tabs/connections-tab.tsx +++ b/components/settings/tabs/connections-tab.tsx @@ -6,6 +6,7 @@ import { GoogleConnection } from "../google-connection"; import { MicrosoftConnection } from "../microsoft-connection"; import { BlooioConnection } from "../blooio-connection"; import { TwilioConnection } from "../twilio-connection"; +import { WhatsAppConnection } from "../whatsapp-connection"; export function ConnectionsTab() { return ( @@ -18,7 +19,7 @@ export function ConnectionsTab() {

Connect messaging services for AI-powered conversations via SMS, - iMessage, and email. + iMessage, WhatsApp, and email.

@@ -27,6 +28,7 @@ export function ConnectionsTab() { + diff --git a/components/settings/whatsapp-connection.tsx b/components/settings/whatsapp-connection.tsx new file mode 100644 index 000000000..158328ade --- /dev/null +++ b/components/settings/whatsapp-connection.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Loader2, + CheckCircle, + XCircle, + ChevronDown, + MessageSquare, + Phone, + ExternalLink, +} from "lucide-react"; +import { toast } from "sonner"; + +interface WhatsAppStatus { + connected: boolean; + configured?: boolean; + businessPhone?: string; + webhookUrl?: string; + verifyToken?: string; + error?: string; +} + +export function WhatsAppConnection() { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [accessToken, setAccessToken] = useState(""); + const [phoneNumberId, setPhoneNumberId] = useState(""); + const [appSecret, setAppSecret] = useState(""); + const [businessPhone, setBusinessPhone] = useState(""); + const [showInstructions, setShowInstructions] = useState(false); + + const fetchStatus = async (signal?: AbortSignal) => { + setIsLoading(true); + try { + const response = await fetch("/api/v1/whatsapp/status", { signal }); + if (!signal?.aborted) { + setStatus(await response.json()); + } + } catch { + if (!signal?.aborted) { + toast.error("Failed to fetch WhatsApp status"); + } + } finally { + if (!signal?.aborted) { + setIsLoading(false); + } + } + }; + + useEffect(() => { + const controller = new AbortController(); + fetchStatus(controller.signal); + return () => controller.abort(); + }, []); + + const handleConnect = async () => { + if (isConnecting) return; + if (!accessToken.trim()) { + toast.error("Please enter your access token"); + return; + } + if (!phoneNumberId.trim()) { + toast.error("Please enter your Phone Number ID"); + return; + } + if (!appSecret.trim()) { + toast.error("Please enter your App Secret"); + return; + } + + setIsConnecting(true); + + try { + const response = await fetch("/api/v1/whatsapp/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accessToken, + phoneNumberId, + appSecret, + businessPhone: businessPhone || undefined, + }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + toast.success( + "WhatsApp connected! Now configure the webhook in Meta.", + ); + setAccessToken(""); + setPhoneNumberId(""); + setAppSecret(""); + setBusinessPhone(""); + void fetchStatus(); + } else { + toast.error(data.error || "Failed to connect WhatsApp"); + } + } catch { + toast.error("Network error. Please check your connection."); + } + + setIsConnecting(false); + }; + + const handleDisconnect = async () => { + if (isDisconnecting) return; + setIsDisconnecting(true); + + try { + const response = await fetch("/api/v1/whatsapp/disconnect", { + method: "DELETE", + }); + + if (response.ok) { + toast.success("WhatsApp disconnected"); + void fetchStatus(); + } else { + const data = await response.json().catch(() => ({})); + toast.error(data.error || "Failed to disconnect"); + } + } catch { + toast.error("Network error. Please check your connection."); + } + + setIsDisconnecting(false); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + return ( + + +
+
+ + + WhatsApp Business + + + Connect WhatsApp Business for AI-powered conversations + +
+ {status?.connected && ( + + + Connected + + )} +
+
+ + {status?.connected ? ( +
+ {/* Connected info */} +
+
+ +
+
+
+ {status.businessPhone || "WhatsApp Business"} +
+
+ WhatsApp Business Connected +
+
+
+ + {/* Webhook URL */} + {status.webhookUrl && ( +
+ +
+ + {status.webhookUrl} + + +
+
+ )} + + {/* Verify Token */} + {status.verifyToken && ( +
+ +
+ + {status.verifyToken} + + +
+
+ )} + + {/* Post-connection setup instructions */} +
+

+ Webhook Setup Instructions +

+
    +
  1. + Go to{" "} + + Meta App Dashboard + + +
  2. +
  3. + Navigate to WhatsApp {">"} Configuration +
  4. +
  5. + Click "Edit" on the Callback URL section +
  6. +
  7. Paste the Webhook URL and Verify Token from above
  8. +
  9. + Subscribe to the "messages" webhook field +
  10. +
+
+ + {/* Capabilities */} +
+

+ Your AI agent can now: +

+
    +
  • • Receive and respond to WhatsApp messages
  • +
  • • Have AI-powered conversations 24/7
  • +
  • • Handle customer inquiries automatically
  • +
+
+ + {/* Disconnect */} +
+
+ Messages are processed in real-time +
+ + + + + + + + Disconnect WhatsApp? + + + This will stop your AI agent from receiving and + sending WhatsApp messages. You can reconnect at + any time. + + + + Cancel + + Disconnect + + + + +
+
+ ) : ( +
+ {/* Setup instructions */} + + + + + +
    +
  1. + Go to{" "} + + developers.facebook.com + + {" "} + and create a Meta Business App +
  2. +
  3. + Add the WhatsApp product to your app +
  4. +
  5. + Go to WhatsApp {">"} API Setup to find your + Phone Number ID +
  6. +
  7. + Go to Settings {">"} Basic to find your App + Secret +
  8. +
  9. + Create a permanent access token via Meta + Business Settings {">"} System Users +
  10. +
  11. Enter the credentials below to connect
  12. +
+
+
+ + {/* Credential fields */} +
+ + setAccessToken(e.target.value)} + /> +

+ Permanent access token from Meta Business Settings +

+
+ +
+ + setPhoneNumberId(e.target.value)} + /> +

+ Found in Meta App Dashboard under WhatsApp {">"} API + Setup +

+
+ +
+ + setAppSecret(e.target.value)} + /> +

+ Found in Meta App Dashboard under Settings {">"}{" "} + Basic +

+
+ +
+ + setBusinessPhone(e.target.value)} + /> +

+ Your WhatsApp Business phone number (for display) +

+
+ + {/* Capabilities preview */} +
+

+ What you can do with WhatsApp: +

+
    +
  • • Have AI conversations via WhatsApp
  • +
  • • Receive real-time customer messages
  • +
  • • Send automated responses 24/7
  • +
  • • Handle inquiries naturally
  • +
+
+ + {/* Connect button */} + +
+ )} +
+
+ ); +} diff --git a/db/migrations/0034_add_whatsapp_identity_columns.sql b/db/migrations/0034_add_whatsapp_identity_columns.sql new file mode 100644 index 000000000..cc2f80cfc --- /dev/null +++ b/db/migrations/0034_add_whatsapp_identity_columns.sql @@ -0,0 +1,40 @@ +-- Migration: Add WhatsApp identity columns to users table +-- Supports WhatsApp authentication for Eliza App +-- Generated via: npx drizzle-kit generate --custom --name=add_whatsapp_identity_columns + +-- Add WhatsApp identity columns (uses IF NOT EXISTS for idempotency) +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "whatsapp_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "whatsapp_name" text;--> statement-breakpoint + +-- Add unique constraint on whatsapp_id (idempotent - checks if exists first) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'users_whatsapp_id_unique' + ) THEN + ALTER TABLE "users" ADD CONSTRAINT "users_whatsapp_id_unique" UNIQUE ("whatsapp_id"); + END IF; +END $$;--> statement-breakpoint + +-- Create partial index for efficient lookups (only indexes non-null values) +CREATE INDEX IF NOT EXISTS "users_whatsapp_id_idx" ON "users" ("whatsapp_id") WHERE "whatsapp_id" IS NOT NULL;--> statement-breakpoint + +-- Add 'whatsapp' to phone_provider enum if not already present +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum WHERE enumlabel = 'whatsapp' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'phone_provider') + ) THEN + ALTER TYPE phone_provider ADD VALUE 'whatsapp'; + END IF; +END $$;--> statement-breakpoint + +-- Add 'whatsapp' to phone_type enum if not already present +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum WHERE enumlabel = 'whatsapp' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'phone_type') + ) THEN + ALTER TYPE phone_type ADD VALUE 'whatsapp'; + END IF; +END $$; diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index f6fec9558..04b40939b 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1771424653235, "tag": "0033_add_engagement_metrics_tables", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1772544695751, + "tag": "0034_add_whatsapp_identity_columns", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/repositories/users.ts b/db/repositories/users.ts index ed5b52093..2d1741a6d 100644 --- a/db/repositories/users.ts +++ b/db/repositories/users.ts @@ -173,6 +173,31 @@ export class UsersRepository { return user as UserWithOrganization | undefined; } + /** + * Finds a user by WhatsApp ID. + */ + async findByWhatsAppId(whatsappId: string): Promise { + return await dbRead.query.users.findFirst({ + where: eq(users.whatsapp_id, whatsappId), + }); + } + + /** + * Finds a user by WhatsApp ID with organization data. + */ + async findByWhatsAppIdWithOrganization( + whatsappId: string, + ): Promise { + const user = await dbRead.query.users.findFirst({ + where: eq(users.whatsapp_id, whatsappId), + with: { + organization: true, + }, + }); + + return user as UserWithOrganization | undefined; + } + /** * Finds a user by wallet address with organization data. */ diff --git a/db/schemas/agent-phone-numbers.ts b/db/schemas/agent-phone-numbers.ts index 275dccc18..c4a9e8d43 100644 --- a/db/schemas/agent-phone-numbers.ts +++ b/db/schemas/agent-phone-numbers.ts @@ -18,6 +18,7 @@ export const phoneProviderEnum = pgEnum("phone_provider", [ "twilio", "blooio", "vonage", + "whatsapp", "other", ]); @@ -29,6 +30,7 @@ export const phoneTypeEnum = pgEnum("phone_type", [ "voice", "both", "imessage", + "whatsapp", ]); /** diff --git a/db/schemas/users.ts b/db/schemas/users.ts index 7dd6df976..5a2b307ff 100644 --- a/db/schemas/users.ts +++ b/db/schemas/users.ts @@ -45,6 +45,10 @@ export const users = pgTable( discord_global_name: text("discord_global_name"), // Discord display name discord_avatar_url: text("discord_avatar_url"), // Discord avatar URL + // WhatsApp identity (Eliza App) + whatsapp_id: text("whatsapp_id").unique(), // WhatsApp user ID (digits only, e.g. "14245074963") + whatsapp_name: text("whatsapp_name"), // WhatsApp profile display name + // User profile email: text("email").unique(), email_verified: boolean("email_verified").default(false), @@ -96,6 +100,7 @@ export const users = pgTable( telegram_id_idx: index("users_telegram_id_idx").on(table.telegram_id), phone_number_idx: index("users_phone_number_idx").on(table.phone_number), discord_id_idx: index("users_discord_id_idx").on(table.discord_id), + whatsapp_id_idx: index("users_whatsapp_id_idx").on(table.whatsapp_id), }), ); diff --git a/lib/constants/secrets.ts b/lib/constants/secrets.ts index ff15176ad..01cc2cb73 100644 --- a/lib/constants/secrets.ts +++ b/lib/constants/secrets.ts @@ -21,6 +21,13 @@ export const TELEGRAM_BOT_USERNAME = "TELEGRAM_BOT_USERNAME"; export const TELEGRAM_BOT_ID = "TELEGRAM_BOT_ID"; export const TELEGRAM_WEBHOOK_SECRET = "TELEGRAM_WEBHOOK_SECRET"; +// WhatsApp secrets +export const WHATSAPP_ACCESS_TOKEN = "WHATSAPP_ACCESS_TOKEN"; +export const WHATSAPP_PHONE_NUMBER_ID = "WHATSAPP_PHONE_NUMBER_ID"; +export const WHATSAPP_APP_SECRET = "WHATSAPP_APP_SECRET"; +export const WHATSAPP_VERIFY_TOKEN = "WHATSAPP_VERIFY_TOKEN"; +export const WHATSAPP_BUSINESS_PHONE = "WHATSAPP_BUSINESS_PHONE"; + // Google OAuth secrets export const GOOGLE_ACCESS_TOKEN = "GOOGLE_ACCESS_TOKEN"; export const GOOGLE_REFRESH_TOKEN = "GOOGLE_REFRESH_TOKEN"; @@ -43,6 +50,13 @@ export const SECRET_NAMES = { BOT_ID: TELEGRAM_BOT_ID, WEBHOOK_SECRET: TELEGRAM_WEBHOOK_SECRET, }, + WHATSAPP: { + ACCESS_TOKEN: WHATSAPP_ACCESS_TOKEN, + PHONE_NUMBER_ID: WHATSAPP_PHONE_NUMBER_ID, + APP_SECRET: WHATSAPP_APP_SECRET, + VERIFY_TOKEN: WHATSAPP_VERIFY_TOKEN, + BUSINESS_PHONE: WHATSAPP_BUSINESS_PHONE, + }, GOOGLE: { ACCESS_TOKEN: GOOGLE_ACCESS_TOKEN, REFRESH_TOKEN: GOOGLE_REFRESH_TOKEN, diff --git a/lib/services/agents/agents.ts b/lib/services/agents/agents.ts index e1f40dd13..2b668f863 100644 --- a/lib/services/agents/agents.ts +++ b/lib/services/agents/agents.ts @@ -51,6 +51,8 @@ export interface SendMessageInput { organizationId: string; streaming?: boolean; attachments?: Attachment[]; + /** Optional character/agent ID to use a specific agent instead of the default */ + characterId?: string; } /** @@ -294,7 +296,7 @@ class AgentsService { * For web chat, use the streaming endpoint directly */ async sendMessage(input: SendMessageInput): Promise { - const { roomId, message, streaming, attachments } = input; + const { roomId, message, streaming, attachments, characterId } = input; // Acquire distributed lock with retry const lock = await distributedLocks.acquireRoomLockWithRetry( @@ -314,7 +316,11 @@ class AgentsService { } try { - const runtime = await agentRuntime.getRuntime(); + // Use specific character runtime if provided (e.g., from WhatsApp/SMS routing), + // otherwise fall back to the default system runtime. + const runtime = characterId + ? await agentRuntime.getRuntimeForCharacter(characterId) + : await agentRuntime.getRuntime(); await agentEventEmitter.emitResponseStarted(roomId, runtime.agentId); @@ -329,10 +335,11 @@ class AgentsService { : ContentType.DOCUMENT, })) ?? []; const { message: agentMessage, usage: messageUsage } = - await agentRuntime.handleMessage(roomId, { - text: message, - attachments: mediaAttachments, - }); + await agentRuntime.handleMessage( + roomId, + { text: message, attachments: mediaAttachments }, + characterId, + ); await agentEventEmitter.emitResponseComplete( roomId, diff --git a/lib/services/eliza-app/config.ts b/lib/services/eliza-app/config.ts index a5a0e0e0a..9843ac60b 100644 --- a/lib/services/eliza-app/config.ts +++ b/lib/services/eliza-app/config.ts @@ -49,6 +49,15 @@ export const elizaAppConfig = { phoneNumber: requireEnv("ELIZA_APP_BLOOIO_PHONE_NUMBER", "+14245074963"), }, + // WhatsApp configuration + whatsapp: { + accessToken: requireEnv("ELIZA_APP_WHATSAPP_ACCESS_TOKEN", ""), + phoneNumberId: requireEnv("ELIZA_APP_WHATSAPP_PHONE_NUMBER_ID", ""), + appSecret: requireEnv("ELIZA_APP_WHATSAPP_APP_SECRET", ""), + verifyToken: requireEnv("ELIZA_APP_WHATSAPP_VERIFY_TOKEN", ""), + phoneNumber: requireEnv("ELIZA_APP_WHATSAPP_PHONE_NUMBER", ""), + }, + // Discord configuration discord: { botToken: requireEnv("ELIZA_APP_DISCORD_BOT_TOKEN", ""), @@ -68,5 +77,13 @@ if (isProduction) { elizaAppConfig.telegram.botToken; elizaAppConfig.blooio.apiKey; elizaAppConfig.blooio.phoneNumber; + elizaAppConfig.discord.botToken; + elizaAppConfig.discord.applicationId; + elizaAppConfig.discord.clientSecret; + elizaAppConfig.whatsapp.accessToken; + elizaAppConfig.whatsapp.phoneNumberId; + elizaAppConfig.whatsapp.appSecret; + elizaAppConfig.whatsapp.verifyToken; + elizaAppConfig.whatsapp.phoneNumber; elizaAppConfig.jwt.secret; } diff --git a/lib/services/eliza-app/index.ts b/lib/services/eliza-app/index.ts index 2cb1b7e24..fca84b1e0 100644 --- a/lib/services/eliza-app/index.ts +++ b/lib/services/eliza-app/index.ts @@ -7,6 +7,7 @@ export { telegramAuthService, type TelegramAuthData } from "./telegram-auth"; export { discordAuthService, type DiscordUserData } from "./discord-auth"; +export { whatsAppAuthService } from "./whatsapp-auth"; export { elizaAppSessionService, type ElizaAppSessionPayload, diff --git a/lib/services/eliza-app/session-service.ts b/lib/services/eliza-app/session-service.ts index 8c1b43b29..19844687b 100644 --- a/lib/services/eliza-app/session-service.ts +++ b/lib/services/eliza-app/session-service.ts @@ -14,6 +14,7 @@ export interface ElizaAppSessionPayload extends JWTPayload { organizationId: string; telegramId?: string; discordId?: string; + whatsappId?: string; phoneNumber?: string; } @@ -27,6 +28,7 @@ export interface ValidatedSession { organizationId: string; telegramId?: string; discordId?: string; + whatsappId?: string; phoneNumber?: string; } @@ -44,7 +46,7 @@ class ElizaAppSessionService { async createSession( userId: string, organizationId: string, - identifiers?: { telegramId?: string; discordId?: string; phoneNumber?: string }, + identifiers?: { telegramId?: string; discordId?: string; whatsappId?: string; phoneNumber?: string }, ): Promise { const now = Math.floor(Date.now() / 1000); const expiresAt = new Date((now + SESSION_DURATION_SECONDS) * 1000); @@ -54,6 +56,7 @@ class ElizaAppSessionService { organizationId, ...(identifiers?.telegramId && { telegramId: identifiers.telegramId }), ...(identifiers?.discordId && { discordId: identifiers.discordId }), + ...(identifiers?.whatsappId && { whatsappId: identifiers.whatsappId }), ...(identifiers?.phoneNumber && { phoneNumber: identifiers.phoneNumber }), }; @@ -96,6 +99,7 @@ class ElizaAppSessionService { organizationId: payload.organizationId, telegramId: payload.telegramId, discordId: payload.discordId, + whatsappId: payload.whatsappId, phoneNumber: payload.phoneNumber, }; } catch (error) { diff --git a/lib/services/eliza-app/user-service.ts b/lib/services/eliza-app/user-service.ts index 880de994b..2c14fa089 100644 --- a/lib/services/eliza-app/user-service.ts +++ b/lib/services/eliza-app/user-service.ts @@ -67,6 +67,13 @@ function generateSlugFromDiscord(username?: string, discordId?: string): string return `discord-${base}-${timestamp}${random}`; } +function generateSlugFromWhatsApp(whatsappId: string): string { + const lastFour = whatsappId.slice(-4); + const random = Math.random().toString(36).substring(2, 8); + const timestamp = Date.now().toString(36).slice(-4); + return `wa-${lastFour}-${timestamp}${random}`; +} + async function ensureUniqueSlug( generateFn: () => string, maxAttempts = 10, @@ -923,6 +930,217 @@ class ElizaAppUserService { return { success: true }; } + + // ============================================================================ + // WhatsApp Methods + // ============================================================================ + + /** + * Find or create user by WhatsApp ID. + * Used by WhatsApp webhook to auto-provision users on first message. + * + * Cross-platform linking scenarios: + * 1. User exists by whatsapp_id → update profile name, return existing + * 2. User exists by phone_number (Telegram/iMessage-first) → link WhatsApp to that user + * 3. Neither exists → create new user with whatsapp_id + auto-derived phone_number + * + * Since WhatsApp ID IS a phone number (digits only), we auto-derive phone_number + * by prepending "+". This means cross-platform linking happens automatically. + */ + async findOrCreateByWhatsAppId( + whatsappId: string, + profileName?: string, + ): Promise { + // Auto-derive E.164 phone number from WhatsApp ID + const derivedPhone = `+${whatsappId.replace(/\D/g, "")}`; + + // Scenario 1: Check if user exists by whatsapp_id (returning WhatsApp user) + const existingWhatsAppUser = await usersRepository.findByWhatsAppIdWithOrganization(whatsappId); + + if (existingWhatsAppUser && existingWhatsAppUser.organization) { + // Update WhatsApp profile name if changed + if (profileName && profileName !== existingWhatsAppUser.whatsapp_name) { + try { + await usersRepository.update(existingWhatsAppUser.id, { + whatsapp_name: profileName, + updated_at: new Date(), + }); + } catch (error) { + // Non-critical - log warning and continue with stale data + logger.warn("[ElizaAppUserService] Failed to update WhatsApp name", { + userId: existingWhatsAppUser.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + logger.info("[ElizaAppUserService] Found existing WhatsApp user", { + userId: existingWhatsAppUser.id, + whatsappId, + }); + + return { + user: existingWhatsAppUser, + organization: existingWhatsAppUser.organization, + isNew: false, + }; + } + + // Scenario 2: Check if user exists by phone_number (Telegram/iMessage-first user) + const existingPhoneUser = await usersRepository.findByPhoneNumberWithOrganization(derivedPhone); + + if (existingPhoneUser && existingPhoneUser.organization) { + // Re-check whatsapp_id to prevent race condition (TOCTOU) + if (existingPhoneUser.whatsapp_id && existingPhoneUser.whatsapp_id !== whatsappId) { + logger.warn("[ElizaAppUserService] Phone user already linked to different WhatsApp (race)", { + phoneUserId: existingPhoneUser.id, + existingWhatsAppId: existingPhoneUser.whatsapp_id, + newWhatsAppId: whatsappId, + }); + throw new Error("WHATSAPP_ALREADY_LINKED"); + } + + // Link WhatsApp to the existing phone-based user + try { + await usersRepository.update(existingPhoneUser.id, { + whatsapp_id: whatsappId, + whatsapp_name: profileName, + updated_at: new Date(), + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + logger.warn("[ElizaAppUserService] Race condition on whatsapp link", { + whatsappId, + phoneUserId: existingPhoneUser.id, + }); + throw new Error("WHATSAPP_ALREADY_LINKED"); + } + throw error; + } + + logger.info("[ElizaAppUserService] Linked WhatsApp to existing phone user (cross-platform)", { + userId: existingPhoneUser.id, + whatsappId, + phone: `***${derivedPhone.slice(-4)}`, + }); + + // Refetch to get updated data + const updatedUser = await usersRepository.findByPhoneNumberWithOrganization(derivedPhone); + return { + user: updatedUser!, + organization: updatedUser!.organization!, + isNew: false, + }; + } + + // Scenario 3: Neither exists - create new user with WhatsApp ID + auto-derived phone + const displayName = profileName || `WhatsApp ***${whatsappId.slice(-4)}`; + const organizationName = `${displayName}'s Workspace`; + + try { + return await createUserWithOrganization({ + userData: { + whatsapp_id: whatsappId, + whatsapp_name: profileName, + phone_number: derivedPhone, + phone_verified: true, // WhatsApp verifies phone numbers + name: displayName, + is_anonymous: false, + }, + organizationName, + slugGenerator: () => generateSlugFromWhatsApp(whatsappId), + }); + } catch (error) { + // Handle race condition: another request created the user first + if (isUniqueConstraintError(error)) { + // Try to find the user that was created by the other request (by whatsapp_id) + const userByWhatsApp = await usersRepository.findByWhatsAppIdWithOrganization(whatsappId); + if (userByWhatsApp && userByWhatsApp.organization) { + logger.info("[ElizaAppUserService] Recovered from race condition (whatsapp)", { + whatsappId, + }); + return { user: userByWhatsApp, organization: userByWhatsApp.organization, isNew: false }; + } + + // Constraint may have been on phone_number (same phone, different WhatsApp ID) + const userByPhone = await usersRepository.findByPhoneNumberWithOrganization(derivedPhone); + if (userByPhone && userByPhone.organization) { + logger.warn("[ElizaAppUserService] Phone already linked by race condition (whatsapp)", { + whatsappId, + phone: `***${derivedPhone.slice(-4)}`, + }); + throw new Error("PHONE_ALREADY_LINKED"); + } + } + throw error; + } + } + + async getByWhatsAppId(whatsappId: string): Promise { + return usersRepository.findByWhatsAppIdWithOrganization(whatsappId); + } + + /** + * Link a WhatsApp account to an existing user. + * Used for session-based linking. + */ + async linkWhatsAppToUser( + userId: string, + whatsappData: { + whatsappId: string; + name?: string; + } + ): Promise<{ success: boolean; error?: string }> { + const { whatsappId, name } = whatsappData; + + // Check if this WhatsApp ID is already linked to a different user + const existingWhatsAppUser = await usersRepository.findByWhatsAppIdWithOrganization(whatsappId); + + if (existingWhatsAppUser && existingWhatsAppUser.id !== userId) { + logger.warn("[ElizaAppUserService] WhatsApp already linked to another user", { + userId, + existingUserId: existingWhatsAppUser.id, + whatsappId, + }); + return { + success: false, + error: "This WhatsApp account is already linked to another account", + }; + } + + // If already linked to the same user, treat as idempotent success + if (existingWhatsAppUser && existingWhatsAppUser.id === userId) { + return { success: true }; + } + + try { + await usersRepository.update(userId, { + whatsapp_id: whatsappId, + whatsapp_name: name, + updated_at: new Date(), + }); + } catch (error) { + // Handle race condition: another request linked this WhatsApp account first + if (isUniqueConstraintError(error)) { + logger.warn("[ElizaAppUserService] WhatsApp linking race condition", { + userId, + whatsappId, + }); + return { + success: false, + error: "This WhatsApp account is already linked to another account", + }; + } + throw error; + } + + logger.info("[ElizaAppUserService] Linked WhatsApp to user", { + userId, + whatsappId, + }); + + return { success: true }; + } } export const elizaAppUserService = new ElizaAppUserService(); diff --git a/lib/services/eliza-app/whatsapp-auth.ts b/lib/services/eliza-app/whatsapp-auth.ts new file mode 100644 index 000000000..5ed570c7f --- /dev/null +++ b/lib/services/eliza-app/whatsapp-auth.ts @@ -0,0 +1,79 @@ +/** + * WhatsApp Webhook Authentication Service + * + * Verifies incoming WhatsApp webhook signatures using HMAC-SHA256 with the App Secret. + * Also handles the webhook verification GET handshake. + */ + +import { logger } from "@/lib/utils/logger"; +import { verifyWhatsAppSignature } from "@/lib/utils/whatsapp-api"; +import { elizaAppConfig } from "./config"; + +class WhatsAppAuthService { + /** + * Verify the X-Hub-Signature-256 header on incoming webhook POST requests. + * + * @param signatureHeader - The X-Hub-Signature-256 header value + * @param rawBody - The raw request body as a string + * @returns true if signature is valid + */ + verifyWebhookSignature(signatureHeader: string, rawBody: string): boolean { + const appSecret = elizaAppConfig.whatsapp.appSecret; + + if (!appSecret) { + logger.error("[WhatsAppAuth] App secret not configured"); + return false; + } + + const isValid = verifyWhatsAppSignature(appSecret, signatureHeader, rawBody); + + if (!isValid) { + logger.warn("[WhatsAppAuth] Webhook signature verification failed"); + } + + return isValid; + } + + /** + * Verify the webhook verification GET request from Meta. + * + * When setting up webhooks, Meta sends a GET request with: + * - hub.mode: "subscribe" + * - hub.verify_token: The verify token you configured + * - hub.challenge: A challenge string to echo back + * + * @returns The challenge string if verification succeeds, null otherwise + */ + verifyWebhookSubscription( + mode: string | null, + verifyToken: string | null, + challenge: string | null, + ): string | null { + const expectedToken = elizaAppConfig.whatsapp.verifyToken; + + if (!expectedToken) { + logger.error("[WhatsAppAuth] Verify token not configured"); + return null; + } + + if (mode !== "subscribe") { + logger.warn("[WhatsAppAuth] Invalid hub.mode", { mode }); + return null; + } + + if (verifyToken !== expectedToken) { + logger.warn("[WhatsAppAuth] Verify token mismatch"); + return null; + } + + if (!challenge) { + logger.warn("[WhatsAppAuth] Missing hub.challenge"); + return null; + } + + logger.info("[WhatsAppAuth] Webhook verification successful"); + return challenge; + } +} + +export const whatsAppAuthService = new WhatsAppAuthService(); diff --git a/lib/services/message-router/index.ts b/lib/services/message-router/index.ts index 3f8cfb23d..fb5334f2d 100644 --- a/lib/services/message-router/index.ts +++ b/lib/services/message-router/index.ts @@ -63,10 +63,10 @@ export interface IncomingMessage { from: string; to: string; body: string; - provider: "twilio" | "blooio"; + provider: "twilio" | "blooio" | "whatsapp"; providerMessageId?: string; mediaUrls?: string[]; - messageType?: "sms" | "mms" | "voice" | "imessage"; + messageType?: "sms" | "mms" | "voice" | "imessage" | "whatsapp"; metadata?: Record; } @@ -88,7 +88,7 @@ export interface SendMessageParams { to: string; from: string; body: string; - provider: "twilio" | "blooio"; + provider: "twilio" | "blooio" | "whatsapp"; mediaUrls?: string[]; organizationId: string; } @@ -232,7 +232,9 @@ class MessageRouterService { url, })); - // Send message to agent via the standard interface + // Send message to agent via the standard interface. + // Pass agentId as characterId so the runtime loads the correct character + // (e.g., "Dr. Alex Chen") instead of the default "Eliza" agent. const response = await agentsService.sendMessage({ roomId, entityId, @@ -240,6 +242,7 @@ class MessageRouterService { organizationId, streaming: false, attachments, + characterId: agentId, }); if (response) { @@ -284,36 +287,45 @@ class MessageRouterService { } /** - * Generate a deterministic entity ID for a phone number + * Generate a deterministic entity ID for a phone number. + * Returns a valid UUID derived from the phone number hash. */ private generateEntityId(phoneNumber: string): string { const normalized = normalizePhoneNumber(phoneNumber); - // Use a simple hash to create a UUID-like ID - const hash = this.secureHash(normalized); - return `phone-${hash}`; + return this.hashToUuid(`entity:${normalized}`); } /** - * Generate a deterministic room ID for a phone conversation + * Generate a deterministic room ID for a phone conversation. + * Returns a valid UUID derived from the agent + phone numbers hash. */ private generateRoomId(agentId: string, from: string, to: string): string { const normalizedFrom = normalizePhoneNumber(from); const normalizedTo = normalizePhoneNumber(to); // Sort to ensure consistency regardless of direction const sorted = [normalizedFrom, normalizedTo].sort().join("-"); - const hash = this.secureHash(`${agentId}:${sorted}`); - return `room-phone-${hash}`; + return this.hashToUuid(`room:${agentId}:${sorted}`); } /** - * Secure hash function for generating deterministic IDs - * Uses SHA-256 for collision resistance and unpredictability + * Generate a deterministic UUID from a string input. + * Uses SHA-256 and formats the first 32 hex chars as a UUID v4-like string. + * The version nibble is set to 4 and the variant bits to 10xx for RFC 4122 compliance. */ - private secureHash(str: string): string { - return createHash("sha256") - .update(str) - .digest("hex") - .substring(0, 16); + private hashToUuid(str: string): string { + const hex = createHash("sha256").update(str).digest("hex").substring(0, 32); + // Format as UUID: 8-4-4-4-12 + // Set version nibble (position 12) to 4 and variant bits (position 16) to 8-b + const chars = hex.split(""); + chars[12] = "4"; // version 4 + chars[16] = ((parseInt(chars[16], 16) & 0x3) | 0x8).toString(16); // variant 10xx + return [ + chars.slice(0, 8).join(""), + chars.slice(8, 12).join(""), + chars.slice(12, 16).join(""), + chars.slice(16, 20).join(""), + chars.slice(20, 32).join(""), + ].join("-"); } /** @@ -344,6 +356,8 @@ class MessageRouterService { return await this.sendViaTwilio(params); } else if (params.provider === "blooio") { return await this.sendViaBlooio(params); + } else if (params.provider === "whatsapp") { + return await this.sendViaWhatsApp(params); } logger.error("[MessageRouter] Unknown provider", { @@ -453,6 +467,50 @@ class MessageRouterService { } } + /** + * Send message via WhatsApp Cloud API. + * Tries org-specific credentials from secrets service first, + * falls back to global elizaAppConfig for the public bot. + */ + private async sendViaWhatsApp(params: SendMessageParams): Promise { + try { + const { sendWhatsAppMessage } = await import("@/lib/utils/whatsapp-api"); + const { secretsService } = await import("@/lib/services/secrets"); + const { WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID } = await import("@/lib/constants/secrets"); + + // Try org-specific credentials first (from secrets service) + let accessToken = await secretsService.get(params.organizationId, WHATSAPP_ACCESS_TOKEN); + let phoneNumberId = await secretsService.get(params.organizationId, WHATSAPP_PHONE_NUMBER_ID); + + // Fall back to global config (for eliza-app public bot) + if (!accessToken || !phoneNumberId) { + const { elizaAppConfig } = await import("@/lib/services/eliza-app/config"); + accessToken = accessToken || elizaAppConfig.whatsapp.accessToken; + phoneNumberId = phoneNumberId || elizaAppConfig.whatsapp.phoneNumberId; + } + + if (!accessToken || !phoneNumberId) { + logger.error("[MessageRouter] Missing WhatsApp credentials", { + organizationId: params.organizationId, + }); + return false; + } + + await sendWhatsAppMessage(accessToken, phoneNumberId, params.to, params.body); + + logger.info("[MessageRouter] WhatsApp message sent successfully", { + organizationId: params.organizationId, + }); + return true; + } catch (error) { + logger.error("[MessageRouter] WhatsApp send error", { + organizationId: params.organizationId, + error, + }); + return false; + } + } + /** * Log a message to the phone_message_log table */ @@ -538,8 +596,8 @@ class MessageRouterService { organizationId: string; agentId: string; phoneNumber: string; - provider: "twilio" | "blooio"; - phoneType?: "sms" | "voice" | "both" | "imessage"; + provider: "twilio" | "blooio" | "whatsapp"; + phoneType?: "sms" | "voice" | "both" | "imessage" | "whatsapp"; friendlyName?: string; capabilities?: { canSendSms?: boolean; diff --git a/lib/services/whatsapp-automation/index.ts b/lib/services/whatsapp-automation/index.ts new file mode 100644 index 000000000..811edc90e --- /dev/null +++ b/lib/services/whatsapp-automation/index.ts @@ -0,0 +1,454 @@ +/** + * WhatsApp Automation Service + * + * Handles credential validation, storage, and message management + * for WhatsApp Business Cloud API integration at the organization level. + * Follows the Blooio/Telegram automation service pattern. + * + * Each organization connects their own WhatsApp Business account + * with credentials stored in the secrets service. + */ + +import crypto from "crypto"; +import { secretsService } from "@/lib/services/secrets"; +import { logger } from "@/lib/utils/logger"; +import { + WHATSAPP_ACCESS_TOKEN, + WHATSAPP_PHONE_NUMBER_ID, + WHATSAPP_APP_SECRET, + WHATSAPP_VERIFY_TOKEN, + WHATSAPP_BUSINESS_PHONE, +} from "@/lib/constants/secrets"; +import { + WHATSAPP_API_BASE, + sendWhatsAppMessage, + verifyWhatsAppSignature, +} from "@/lib/utils/whatsapp-api"; + +// Use ELIZA_API_URL (ngrok) for local dev webhooks, otherwise NEXT_PUBLIC_APP_URL +const WEBHOOK_BASE_URL = + process.env.ELIZA_API_URL || + process.env.NEXT_PUBLIC_APP_URL || + "https://eliza.gg"; + +// Cache TTL for connection status (5 minutes) +const STATUS_CACHE_TTL_MS = 5 * 60 * 1000; + +interface CachedStatus { + status: WhatsAppConnectionStatus; + cachedAt: number; +} + +export interface WhatsAppConnectionStatus { + connected: boolean; + configured: boolean; + businessPhone?: string; + error?: string; +} + +export interface WhatsAppCredentials { + accessToken: string; + phoneNumberId: string; + appSecret: string; + verifyToken?: string; // Auto-generated if not provided + businessPhone?: string; +} + +class WhatsAppAutomationService { + // In-memory cache for connection status + private statusCache = new Map(); + + /** + * Invalidate cached status for an organization. + */ + invalidateStatusCache(organizationId: string): void { + this.statusCache.delete(organizationId); + } + + /** + * Generate a random verify token for webhook handshake. + * Used when registering the webhook URL in Meta App Dashboard. + */ + generateVerifyToken(): string { + return `wa_verify_${crypto.randomBytes(24).toString("hex")}`; + } + + /** + * Validate a WhatsApp access token by calling Meta Graph API. + * Fetches the phone number ID details to confirm the token works. + */ + async validateAccessToken( + accessToken: string, + phoneNumberId: string, + ): Promise<{ + valid: boolean; + phoneDisplay?: string; + error?: string; + }> { + if (!accessToken || accessToken.trim() === "") { + return { valid: false, error: "Access token is required" }; + } + if (!phoneNumberId || phoneNumberId.trim() === "") { + return { valid: false, error: "Phone Number ID is required" }; + } + + try { + // Validate by fetching phone number details from Meta Graph API + const url = `${WHATSAPP_API_BASE}/${phoneNumberId}?fields=display_phone_number,verified_name,quality_rating`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const errorText = await response.text(); + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid access token" }; + } + if (response.status === 400) { + return { valid: false, error: "Invalid Phone Number ID" }; + } + logger.warn("[WhatsAppAutomation] Token validation failed", { + status: response.status, + error: errorText.slice(0, 200), + }); + return { valid: false, error: "Validation failed. Please check your credentials." }; + } + + const data = await response.json(); + + logger.info("[WhatsAppAutomation] Access token validated successfully", { + phoneNumberId, + displayPhone: data.display_phone_number, + verifiedName: data.verified_name, + }); + + return { + valid: true, + phoneDisplay: data.display_phone_number, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + logger.warn("[WhatsAppAutomation] Token validation error", { error: message }); + return { valid: false, error: "Validation failed due to network error. Please try again." }; + } + } + + /** + * Store WhatsApp credentials in the secrets service. + * Handles the case where secrets already exist by updating them. + */ + async storeCredentials( + organizationId: string, + userId: string, + credentials: WhatsAppCredentials, + ): Promise { + const audit = { + actorType: "user" as const, + actorId: userId, + source: "whatsapp-automation", + }; + + // Helper to create or update a secret + const createOrUpdateSecret = async (name: string, value: string) => { + try { + await secretsService.create( + { + organizationId, + name, + value, + scope: "organization", + createdBy: userId, + }, + audit, + ); + } catch (err) { + // If secret already exists, find it and update it + if (err instanceof Error && err.message.includes("already exists")) { + logger.info("[WhatsAppAutomation] Secret exists, updating", { name }); + const existingSecrets = await secretsService.list(organizationId); + const existingSecret = existingSecrets.find((s) => s.name === name); + if (existingSecret) { + await secretsService.rotate( + existingSecret.id, + organizationId, + value, + audit, + ); + } else { + throw err; + } + } else { + throw err; + } + } + }; + + await createOrUpdateSecret(WHATSAPP_ACCESS_TOKEN, credentials.accessToken); + await createOrUpdateSecret(WHATSAPP_PHONE_NUMBER_ID, credentials.phoneNumberId); + await createOrUpdateSecret(WHATSAPP_APP_SECRET, credentials.appSecret); + + // Auto-generate verify token if not provided + const verifyToken = credentials.verifyToken || this.generateVerifyToken(); + await createOrUpdateSecret(WHATSAPP_VERIFY_TOKEN, verifyToken); + + if (credentials.businessPhone) { + await createOrUpdateSecret(WHATSAPP_BUSINESS_PHONE, credentials.businessPhone); + } + + // Invalidate cache so next status check fetches fresh data + this.invalidateStatusCache(organizationId); + + logger.info("[WhatsAppAutomation] Credentials stored", { + organizationId, + hasBusinessPhone: !!credentials.businessPhone, + }); + } + + /** + * Remove WhatsApp credentials (disconnect). + */ + async removeCredentials( + organizationId: string, + userId: string, + ): Promise { + const audit = { + actorType: "user" as const, + actorId: userId, + source: "whatsapp-automation", + }; + + const secretNames = [ + WHATSAPP_ACCESS_TOKEN, + WHATSAPP_PHONE_NUMBER_ID, + WHATSAPP_APP_SECRET, + WHATSAPP_VERIFY_TOKEN, + WHATSAPP_BUSINESS_PHONE, + ]; + + // Get all secrets once (not inside the loop) for efficiency + const existingSecrets = await secretsService.list(organizationId); + + for (const name of secretNames) { + const secret = existingSecrets.find((s) => s.name === name); + if (secret) { + await secretsService.delete(secret.id, organizationId, audit); + logger.info("[WhatsAppAutomation] Deleted secret", { + name, + organizationId, + }); + } + } + + // Invalidate cache so next status check fetches fresh data + this.invalidateStatusCache(organizationId); + + logger.info("[WhatsAppAutomation] Credentials removed", { organizationId }); + } + + /** + * Get access token for an organization. + * No env fallback in production to prevent multi-tenancy violation. + */ + async getAccessToken(organizationId: string): Promise { + const fromSecrets = await secretsService.get(organizationId, WHATSAPP_ACCESS_TOKEN); + if (fromSecrets) return fromSecrets; + if (process.env.NODE_ENV !== "production") { + return process.env.WHATSAPP_ACCESS_TOKEN || null; + } + return null; + } + + /** + * Get phone number ID for an organization. + */ + async getPhoneNumberId(organizationId: string): Promise { + const fromSecrets = await secretsService.get(organizationId, WHATSAPP_PHONE_NUMBER_ID); + if (fromSecrets) return fromSecrets; + if (process.env.NODE_ENV !== "production") { + return process.env.WHATSAPP_PHONE_NUMBER_ID || null; + } + return null; + } + + /** + * Get app secret for an organization. + */ + async getAppSecret(organizationId: string): Promise { + const fromSecrets = await secretsService.get(organizationId, WHATSAPP_APP_SECRET); + if (fromSecrets) return fromSecrets; + if (process.env.NODE_ENV !== "production") { + return process.env.WHATSAPP_APP_SECRET || null; + } + return null; + } + + /** + * Get verify token for an organization. + */ + async getVerifyToken(organizationId: string): Promise { + return secretsService.get(organizationId, WHATSAPP_VERIFY_TOKEN); + } + + /** + * Get business phone for an organization (display purposes). + */ + async getBusinessPhone(organizationId: string): Promise { + return secretsService.get(organizationId, WHATSAPP_BUSINESS_PHONE); + } + + /** + * Verify a webhook signature using an organization's app secret. + */ + async verifyWebhookSignature( + organizationId: string, + signatureHeader: string, + rawBody: string, + ): Promise { + const appSecret = await this.getAppSecret(organizationId); + if (!appSecret) { + logger.warn("[WhatsAppAutomation] No app secret configured", { organizationId }); + return false; + } + return verifyWhatsAppSignature(appSecret, signatureHeader, rawBody); + } + + /** + * Verify a webhook subscription handshake using an organization's verify token. + */ + async verifyWebhookSubscription( + organizationId: string, + mode: string | null, + verifyToken: string | null, + challenge: string | null, + ): Promise { + if (mode !== "subscribe" || !verifyToken || !challenge) { + return null; + } + + const storedToken = await this.getVerifyToken(organizationId); + if (!storedToken || verifyToken !== storedToken) { + return null; + } + + return challenge; + } + + /** + * Get connection status for an organization. + * Results are cached for STATUS_CACHE_TTL_MS to reduce API calls. + */ + async getConnectionStatus( + organizationId: string, + options?: { skipCache?: boolean }, + ): Promise { + // Check cache first (unless explicitly skipped) + if (!options?.skipCache) { + const cached = this.statusCache.get(organizationId); + if (cached && Date.now() - cached.cachedAt < STATUS_CACHE_TTL_MS) { + return cached.status; + } + } + + const [accessToken, phoneNumberId, businessPhone] = await Promise.all([ + this.getAccessToken(organizationId), + this.getPhoneNumberId(organizationId), + this.getBusinessPhone(organizationId), + ]); + + if (!accessToken || !phoneNumberId) { + const status: WhatsAppConnectionStatus = { + connected: false, + configured: false, + }; + this.statusCache.set(organizationId, { status, cachedAt: Date.now() }); + return status; + } + + // Validate the access token is still working + const validation = await this.validateAccessToken(accessToken, phoneNumberId); + + if (validation.valid) { + const status: WhatsAppConnectionStatus = { + connected: true, + configured: true, + businessPhone: businessPhone || validation.phoneDisplay || undefined, + }; + this.statusCache.set(organizationId, { status, cachedAt: Date.now() }); + return status; + } + + // Token exists but validation failed (expired or revoked) + const status: WhatsAppConnectionStatus = { + connected: false, + configured: true, + businessPhone: businessPhone || undefined, + error: validation.error || "Access token may be invalid. Try reconnecting.", + }; + // Cache with shorter TTL for error state (1 minute) + this.statusCache.set(organizationId, { + status, + cachedAt: Date.now() - STATUS_CACHE_TTL_MS + 60_000, + }); + return status; + } + + /** + * Get the webhook URL for an organization. + */ + getWebhookUrl(organizationId: string): string { + return `${WEBHOOK_BASE_URL}/api/webhooks/whatsapp/${organizationId}`; + } + + /** + * Send a text message via WhatsApp using organization-specific credentials. + */ + async sendMessage( + organizationId: string, + to: string, + text: string, + ): Promise<{ + success: boolean; + messageId?: string; + error?: string; + }> { + const [accessToken, phoneNumberId] = await Promise.all([ + this.getAccessToken(organizationId), + this.getPhoneNumberId(organizationId), + ]); + + if (!accessToken || !phoneNumberId) { + return { success: false, error: "WhatsApp not configured" }; + } + + try { + const response = await sendWhatsAppMessage(accessToken, phoneNumberId, to, text); + const messageId = response.messages?.[0]?.id; + + logger.info("[WhatsAppAutomation] Message sent", { + organizationId, + to, + messageId, + }); + + return { success: true, messageId }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + logger.error("[WhatsAppAutomation] Failed to send message", { + organizationId, + to, + error: message, + }); + return { success: false, error: message }; + } + } + + /** + * Check if WhatsApp is configured (has stored credentials). + */ + async isConfigured(organizationId: string): Promise { + const accessToken = await this.getAccessToken(organizationId); + return Boolean(accessToken); + } +} + +export const whatsappAutomationService = new WhatsAppAutomationService(); diff --git a/lib/utils/deterministic-uuid.ts b/lib/utils/deterministic-uuid.ts index b9778de85..d3b3df90e 100644 --- a/lib/utils/deterministic-uuid.ts +++ b/lib/utils/deterministic-uuid.ts @@ -20,7 +20,7 @@ export function generateDeterministicUUID(input: string): string { * Generate room ID for Eliza App conversations */ export function generateElizaAppRoomId( - channel: "telegram" | "imessage" | "discord", + channel: "telegram" | "imessage" | "discord" | "whatsapp", agentId: string, identifier: string, ): string { @@ -31,7 +31,7 @@ export function generateElizaAppRoomId( * Generate entity ID for Eliza App users */ export function generateElizaAppEntityId( - channel: "telegram" | "imessage" | "discord", + channel: "telegram" | "imessage" | "discord" | "whatsapp", identifier: string, ): string { return generateDeterministicUUID(`eliza-app:${channel}:user:${identifier}`); diff --git a/lib/utils/idempotency.ts b/lib/utils/idempotency.ts index b4e908250..2ad0399a2 100644 --- a/lib/utils/idempotency.ts +++ b/lib/utils/idempotency.ts @@ -93,6 +93,19 @@ export async function markAsProcessed(key: string, source = "unknown"): Promise< } } +/** + * Remove a processing mark to allow retry. + * Used when a message was claimed but processing failed (e.g., lock timeout). + * This allows the next webhook delivery to re-process the message. + */ +export async function removeProcessedMark(key: string): Promise { + try { + await dbWrite.delete(idempotencyKeys).where(eq(idempotencyKeys.key, key)); + } catch (error) { + logger.error("[Idempotency] Error removing key", { key, error: getErrorMessage(error) }); + } +} + /** * Get count of active (non-expired) idempotency keys. For monitoring. */ diff --git a/lib/utils/whatsapp-api.ts b/lib/utils/whatsapp-api.ts new file mode 100644 index 000000000..8145c162d --- /dev/null +++ b/lib/utils/whatsapp-api.ts @@ -0,0 +1,374 @@ +/** + * WhatsApp Cloud API Utilities + * + * Shared constants and helpers for WhatsApp Business Cloud API interactions. + * Handles webhook signature verification, message sending, and payload parsing. + */ + +import crypto from "crypto"; +import { z } from "zod"; +import { logger } from "./logger"; + +export const WHATSAPP_API_BASE = "https://graph.facebook.com/v21.0"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface WhatsAppSendMessageRequest { + messaging_product: "whatsapp"; + recipient_type?: "individual"; + to: string; + type: "text" | "image" | "document" | "audio" | "video"; + text?: { body: string }; + image?: { link: string; caption?: string }; + document?: { link: string; caption?: string; filename?: string }; + audio?: { link: string }; + video?: { link: string; caption?: string }; +} + +// Zod schema for send-message API response validation +const WhatsAppSendMessageResponseSchema = z.object({ + messaging_product: z.string(), + contacts: z.array(z.object({ input: z.string(), wa_id: z.string() })), + messages: z.array(z.object({ id: z.string(), message_status: z.string().optional() })), +}); + +export type WhatsAppSendMessageResponse = z.infer; + +export interface WhatsAppMarkReadRequest { + messaging_product: "whatsapp"; + status: "read"; + message_id: string; +} + +/** A single message extracted from the webhook payload */ +export interface WhatsAppIncomingMessage { + messageId: string; + from: string; // WhatsApp ID (digits only, e.g. "14245074963") + timestamp: string; + type: string; + text?: string; + profileName?: string; + phoneNumberId: string; // The business phone number ID that received the message +} + +// ============================================================================ +// Webhook Payload Schemas (Zod) +// ============================================================================ + +const WhatsAppWebhookMessageSchema = z.object({ + id: z.string(), + from: z.string(), + timestamp: z.string(), + type: z.string(), + text: z.object({ body: z.string() }).optional(), + image: z.object({ + id: z.string(), + mime_type: z.string().optional(), + sha256: z.string().optional(), + caption: z.string().optional(), + }).optional(), +}); + +const WhatsAppWebhookContactSchema = z.object({ + profile: z.object({ name: z.string() }), + wa_id: z.string(), +}); + +const WhatsAppWebhookValueSchema = z.object({ + messaging_product: z.literal("whatsapp"), + metadata: z.object({ + display_phone_number: z.string(), + phone_number_id: z.string(), + }), + contacts: z.array(WhatsAppWebhookContactSchema).optional(), + messages: z.array(WhatsAppWebhookMessageSchema).optional(), + statuses: z.array(z.object({ + id: z.string(), + status: z.string(), + timestamp: z.string(), + recipient_id: z.string(), + })).optional(), +}); + +const WhatsAppWebhookChangeSchema = z.object({ + value: WhatsAppWebhookValueSchema, + field: z.string(), +}); + +const WhatsAppWebhookEntrySchema = z.object({ + id: z.string(), + changes: z.array(WhatsAppWebhookChangeSchema), +}); + +export const WhatsAppWebhookPayloadSchema = z.object({ + object: z.literal("whatsapp_business_account"), + entry: z.array(WhatsAppWebhookEntrySchema), +}); + +export type WhatsAppWebhookPayload = z.infer; + +// ============================================================================ +// Webhook Signature Verification +// ============================================================================ + +/** + * Verify WhatsApp webhook signature (X-Hub-Signature-256). + * + * Meta signs webhook payloads using HMAC-SHA256 with the App Secret. + * The signature is in the format: sha256= + */ +export function verifyWhatsAppSignature( + appSecret: string, + signatureHeader: string, + rawBody: string, +): boolean { + if (!signatureHeader || !appSecret) { + return false; + } + + try { + // Signature format: sha256= + const expectedSignature = signatureHeader.replace("sha256=", ""); + + // Compute HMAC-SHA256 + const computedSignature = crypto + .createHmac("sha256", appSecret) + .update(rawBody) + .digest("hex"); + + // Use constant-time comparison to prevent timing attacks + const expectedBuffer = Buffer.from(expectedSignature, "hex"); + const computedBuffer = Buffer.from(computedSignature, "hex"); + + if (expectedBuffer.length !== computedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, computedBuffer); + } catch { + return false; + } +} + +// ============================================================================ +// Payload Parsing +// ============================================================================ + +/** + * Parse and validate a WhatsApp webhook payload. + * Returns the validated payload or throws a ZodError. + */ +export function parseWhatsAppWebhookPayload(data: unknown): WhatsAppWebhookPayload { + return WhatsAppWebhookPayloadSchema.parse(data); +} + +/** + * Extract incoming messages from a parsed WhatsApp webhook payload. + * Returns an array of simplified message objects. + */ +export function extractWhatsAppMessages(payload: WhatsAppWebhookPayload): WhatsAppIncomingMessage[] { + const messages: WhatsAppIncomingMessage[] = []; + + for (const entry of payload.entry) { + for (const change of entry.changes) { + if (change.field !== "messages") continue; + + const { value } = change; + if (!value.messages) continue; + + const contactMap = new Map(); + if (value.contacts) { + for (const contact of value.contacts) { + contactMap.set(contact.wa_id, contact.profile.name); + } + } + + for (const msg of value.messages) { + messages.push({ + messageId: msg.id, + from: msg.from, + timestamp: msg.timestamp, + type: msg.type, + text: msg.text?.body, + profileName: contactMap.get(msg.from), + phoneNumberId: value.metadata.phone_number_id, + }); + } + } + } + + return messages; +} + +// ============================================================================ +// Message Sending +// ============================================================================ + +/** + * Send a text message via WhatsApp Cloud API. + */ +export async function sendWhatsAppMessage( + accessToken: string, + phoneNumberId: string, + to: string, + text: string, +): Promise { + const url = `${WHATSAPP_API_BASE}/${phoneNumberId}/messages`; + + const body: WhatsAppSendMessageRequest = { + messaging_product: "whatsapp", + recipient_type: "individual", + to, + type: "text", + text: { body: text }, + }; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`WhatsApp API error (${response.status}): ${responseText}`); + } + + try { + const parsed = JSON.parse(responseText); + return WhatsAppSendMessageResponseSchema.parse(parsed); + } catch (parseError) { + if (parseError instanceof z.ZodError) { + throw new Error( + `Unexpected WhatsApp API response shape: ${parseError.message} (raw: ${responseText.slice(0, 200)})`, + ); + } + throw new Error(`Invalid JSON response from WhatsApp: ${responseText}`); + } +} + +/** + * Mark a message as read via WhatsApp Cloud API. + */ +export async function markWhatsAppMessageAsRead( + accessToken: string, + phoneNumberId: string, + messageId: string, +): Promise { + const url = `${WHATSAPP_API_BASE}/${phoneNumberId}/messages`; + + const body: WhatsAppMarkReadRequest = { + messaging_product: "whatsapp", + status: "read", + message_id: messageId, + }; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`WhatsApp mark-read error (${response.status}): ${errorText}`); + } +} + +// ============================================================================ +// Typing Indicators +// ============================================================================ + +/** + * Send a typing indicator via WhatsApp Cloud API. + * + * Piggybacks on the mark-as-read endpoint with a `typing_indicator` field. + * The indicator auto-dismisses after 25 seconds or when a response is sent. + * Non-critical; failures are logged at debug level but never throw. + */ +export async function sendWhatsAppTypingIndicator( + accessToken: string, + phoneNumberId: string, + messageId: string, +): Promise { + if (!accessToken || !phoneNumberId) return; + + const url = `${WHATSAPP_API_BASE}/${phoneNumberId}/messages`; + + try { + await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + messaging_product: "whatsapp", + status: "read", + message_id: messageId, + typing_indicator: { type: "text" }, + }), + }); + } catch (error) { + logger.debug("[WhatsApp] Failed to send typing indicator", { + phoneNumberId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Start a periodic typing indicator that auto-refreshes every 20 seconds. + * Returns a cleanup function to stop the interval. + * WhatsApp clears typing after 25s, so we refresh at 20s to maintain continuity. + */ +export function startWhatsAppTypingIndicator( + accessToken: string, + phoneNumberId: string, + messageId: string, +): () => void { + sendWhatsAppTypingIndicator(accessToken, phoneNumberId, messageId); + const interval = setInterval( + () => sendWhatsAppTypingIndicator(accessToken, phoneNumberId, messageId), + 20_000, + ); + return () => clearInterval(interval); +} + +// ============================================================================ +// Phone Number Utilities +// ============================================================================ + +/** + * Convert a WhatsApp ID (digits only) to E.164 format. + * WhatsApp IDs are phone numbers without the "+" prefix. + * e.g., "14245074963" -> "+14245074963" + */ +export function whatsappIdToE164(whatsappId: string): string { + const digits = whatsappId.replace(/\D/g, ""); + return `+${digits}`; +} + +/** + * Convert an E.164 phone number to WhatsApp ID format. + * e.g., "+14245074963" -> "14245074963" + */ +export function e164ToWhatsappId(phoneNumber: string): string { + return phoneNumber.replace(/^\+/, "").replace(/\D/g, ""); +} + +/** + * Validate that a string looks like a WhatsApp ID (digits only, 7-15 chars). + */ +export function isValidWhatsAppId(id: string): boolean { + return /^\d{7,15}$/.test(id); +} diff --git a/scripts/cleanup-eliza-app-user.ts b/scripts/cleanup-eliza-app-user.ts index b998b3e09..0b95a8bad 100644 --- a/scripts/cleanup-eliza-app-user.ts +++ b/scripts/cleanup-eliza-app-user.ts @@ -2,12 +2,15 @@ /** * Cleanup Eliza App User Data * - * Removes user, organization, and related data for testing phone/telegram auth. + * Removes user, organization, and related data for testing auth flows. * Useful when you need a fresh start for testing login flows. * * Usage: * bun run scripts/cleanup-eliza-app-user.ts +14155552671 * bun run scripts/cleanup-eliza-app-user.ts --telegram 123456789 + * bun run scripts/cleanup-eliza-app-user.ts --discord 987654321 + * bun run scripts/cleanup-eliza-app-user.ts --whatsapp 14155552671 + * bun run scripts/cleanup-eliza-app-user.ts --id * bun run scripts/cleanup-eliza-app-user.ts --all-test-users */ @@ -150,12 +153,120 @@ async function cleanupByTelegram(telegramId: string) { console.log(`\n✅ Cleanup complete for Telegram ${telegramId}\n`); } +async function cleanupByDiscord(discordId: string) { + console.log(`\n🧹 Cleaning up user with Discord ID: ${discordId}\n`); + + const user = await db.query.users.findFirst({ + where: eq(users.discord_id, discordId), + with: { organization: true }, + }); + + if (!user) { + console.log(` ℹ No user found with Discord ID ${discordId}`); + return; + } + + console.log(` Found user: ${user.id} (${user.name || user.discord_username || "unnamed"})`); + console.log(` Discord: ${user.discord_username || "N/A"} (${user.discord_global_name || "N/A"})`); + console.log(` Organization: ${user.organization?.id} (${user.organization?.name || "unnamed"})`); + + if (user.organization_id) { + await db.delete(organizations).where(eq(organizations.id, user.organization_id)); + console.log(` ✓ Deleted organization and all related data`); + } else { + await db.delete(users).where(eq(users.id, user.id)); + console.log(` ✓ Deleted user (no organization)`); + } + + await clearRedisKeys([ + `${SESSION_KEY_PREFIX}*`, + ]); + + console.log(`\n✅ Cleanup complete for Discord ${discordId}\n`); +} + +async function cleanupByWhatsApp(whatsappId: string) { + console.log(`\n🧹 Cleaning up user with WhatsApp ID: ${whatsappId}\n`); + + const user = await db.query.users.findFirst({ + where: eq(users.whatsapp_id, whatsappId), + with: { organization: true }, + }); + + if (!user) { + console.log(` ℹ No user found with WhatsApp ID ${whatsappId}`); + return; + } + + console.log(` Found user: ${user.id} (${user.name || user.whatsapp_name || "unnamed"})`); + console.log(` Organization: ${user.organization?.id} (${user.organization?.name || "unnamed"})`); + + if (user.organization_id) { + await db.delete(organizations).where(eq(organizations.id, user.organization_id)); + console.log(` ✓ Deleted organization and all related data`); + } else { + await db.delete(users).where(eq(users.id, user.id)); + console.log(` ✓ Deleted user (no organization)`); + } + + await clearRedisKeys([ + `${SESSION_KEY_PREFIX}*`, + ]); + + console.log(`\n✅ Cleanup complete for WhatsApp ${whatsappId}\n`); +} + +async function cleanupById(userId: string) { + console.log(`\n🧹 Cleaning up user with ID: ${userId}\n`); + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { organization: true }, + }); + + if (!user) { + console.log(` ℹ No user found with ID ${userId}`); + return; + } + + const identifiers = [ + user.phone_number ? `phone: ${user.phone_number}` : null, + user.telegram_id ? `telegram: ${user.telegram_id}` : null, + user.discord_id ? `discord: ${user.discord_id}` : null, + user.whatsapp_id ? `whatsapp: ${user.whatsapp_id}` : null, + user.email ? `email: ${user.email}` : null, + ].filter(Boolean).join(", "); + + console.log(` Found user: ${user.id} (${user.name || "unnamed"})`); + if (identifiers) console.log(` Identifiers: ${identifiers}`); + console.log(` Organization: ${user.organization?.id} (${user.organization?.name || "unnamed"})`); + + if (user.organization_id) { + await db.delete(organizations).where(eq(organizations.id, user.organization_id)); + console.log(` ✓ Deleted organization and all related data`); + } else { + await db.delete(users).where(eq(users.id, user.id)); + console.log(` ✓ Deleted user (no organization)`); + } + + await clearRedisKeys([ + `${SESSION_KEY_PREFIX}*`, + ]); + + console.log(`\n✅ Cleanup complete for user ${userId}\n`); +} + async function cleanupAllTestUsers() { console.log(`\n🧹 Cleaning up ALL Eliza App test users\n`); - console.log(` Looking for users with phone_number or telegram_id...\n`); + console.log(` Looking for users with phone_number, telegram_id, discord_id, or whatsapp_id...\n`); const testUsers = await db.query.users.findMany({ - where: or(isNotNull(users.phone_number), isNotNull(users.telegram_id)), + where: or( + isNotNull(users.phone_number), + isNotNull(users.telegram_id), + isNotNull(users.discord_id), + isNotNull(users.whatsapp_id), + ), with: { organization: true }, }); @@ -170,6 +281,8 @@ async function cleanupAllTestUsers() { const identifiers = [ user.phone_number ? `phone: ${user.phone_number}` : null, user.telegram_id ? `telegram: ${user.telegram_id}` : null, + user.discord_id ? `discord: ${user.discord_id}` : null, + user.whatsapp_id ? `whatsapp: ${user.whatsapp_id}` : null, ].filter(Boolean).join(", "); console.log(` - ${user.id} (${user.name || "unnamed"}) [${identifiers}]`); @@ -197,12 +310,18 @@ async function main() { Usage: bun run scripts/cleanup-eliza-app-user.ts bun run scripts/cleanup-eliza-app-user.ts --telegram + bun run scripts/cleanup-eliza-app-user.ts --discord + bun run scripts/cleanup-eliza-app-user.ts --whatsapp + bun run scripts/cleanup-eliza-app-user.ts --id bun run scripts/cleanup-eliza-app-user.ts --all-test-users Examples: bun run scripts/cleanup-eliza-app-user.ts +14155552671 bun run scripts/cleanup-eliza-app-user.ts 4155552671 bun run scripts/cleanup-eliza-app-user.ts --telegram 123456789 + bun run scripts/cleanup-eliza-app-user.ts --discord 987654321 + bun run scripts/cleanup-eliza-app-user.ts --whatsapp 14155552671 + bun run scripts/cleanup-eliza-app-user.ts --id a1b2c3d4-e5f6-7890-abcd-ef1234567890 bun run scripts/cleanup-eliza-app-user.ts --all-test-users `); process.exit(1); @@ -212,6 +331,12 @@ Examples: await cleanupAllTestUsers(); } else if (args[0] === "--telegram" && args[1]) { await cleanupByTelegram(args[1]); + } else if (args[0] === "--discord" && args[1]) { + await cleanupByDiscord(args[1]); + } else if (args[0] === "--whatsapp" && args[1]) { + await cleanupByWhatsApp(args[1]); + } else if (args[0] === "--id" && args[1]) { + await cleanupById(args[1]); } else { await cleanupByPhone(args[0]); } diff --git a/tests/integration/whatsapp-webhook-e2e.test.ts b/tests/integration/whatsapp-webhook-e2e.test.ts new file mode 100644 index 000000000..9bdf2ea93 --- /dev/null +++ b/tests/integration/whatsapp-webhook-e2e.test.ts @@ -0,0 +1,188 @@ +/** + * E2E Integration Tests for WhatsApp Webhook Handler + * + * Tests the WhatsApp webhook endpoints for the Eliza App: + * - GET: Webhook verification handshake + * - POST: Incoming message processing + * - Signature verification + * - User auto-provisioning + * - Cross-platform linking (WhatsApp + Telegram/iMessage) + * + * These tests require a running database and server. + * Run with: DATABASE_URL=... TEST_BASE_URL=... bun test tests/integration/whatsapp-webhook-e2e.test.ts + */ + +import { describe, it, expect, beforeAll } from "bun:test"; +import * as crypto from "crypto"; + +const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000"; +const APP_SECRET = process.env.ELIZA_APP_WHATSAPP_APP_SECRET || "test_app_secret"; +const VERIFY_TOKEN = process.env.ELIZA_APP_WHATSAPP_VERIFY_TOKEN || "test_verify_token"; + +function makeSignature(body: string, secret: string): string { + return "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("WhatsApp Webhook E2E Tests", () => { + const WEBHOOK_URL = `${BASE_URL}/api/eliza-app/webhook/whatsapp`; + + describe("GET - Webhook Verification", () => { + it("returns challenge when verification succeeds", async () => { + const challenge = "1234567890"; + const url = `${WEBHOOK_URL}?hub.mode=subscribe&hub.verify_token=${VERIFY_TOKEN}&hub.challenge=${challenge}`; + + const response = await fetch(url); + + if (response.ok) { + const text = await response.text(); + expect(text).toBe(challenge); + } else { + // If verify token doesn't match the server's config, that's expected + // The test validates the endpoint exists and responds + expect([200, 403]).toContain(response.status); + } + }); + + it("returns 403 for wrong verify token", async () => { + const url = `${WEBHOOK_URL}?hub.mode=subscribe&hub.verify_token=wrong_token&hub.challenge=123`; + const response = await fetch(url); + expect(response.status).toBe(403); + }); + + it("returns 403 for wrong mode", async () => { + const url = `${WEBHOOK_URL}?hub.mode=unsubscribe&hub.verify_token=${VERIFY_TOKEN}&hub.challenge=123`; + const response = await fetch(url); + expect(response.status).toBe(403); + }); + + it("returns 403 when missing challenge", async () => { + const url = `${WEBHOOK_URL}?hub.mode=subscribe&hub.verify_token=${VERIFY_TOKEN}`; + const response = await fetch(url); + expect(response.status).toBe(403); + }); + }); + + describe("POST - Incoming Messages", () => { + it("rejects request with invalid signature", async () => { + const payload = JSON.stringify({ + object: "whatsapp_business_account", + entry: [], + }); + + const response = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": "sha256=invalid_signature", + }, + body: payload, + }); + + expect(response.status).toBe(401); + }); + + it("rejects malformed JSON", async () => { + const body = "not valid json"; + const signature = makeSignature(body, APP_SECRET); + + const response = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + }, + body, + }); + + // 400 (invalid JSON) or 401 (if sig check fails with real secret) + expect([400, 401]).toContain(response.status); + }); + + it("rejects payload with wrong object type", async () => { + const payload = JSON.stringify({ + object: "page", + entry: [], + }); + const signature = makeSignature(payload, APP_SECRET); + + const response = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + }, + body: payload, + }); + + // 400 (invalid schema) or 401 (if sig check fails with real secret) + expect([400, 401]).toContain(response.status); + }); + + it("accepts valid webhook payload with proper signature", async () => { + const payload = JSON.stringify({ + object: "whatsapp_business_account", + entry: [{ + id: "test_biz_account", + changes: [{ + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "test_phone_id", + }, + statuses: [{ + id: "wamid.test", + status: "delivered", + timestamp: "1706745600", + recipient_id: "14155551234", + }], + }, + field: "messages", + }], + }], + }); + const signature = makeSignature(payload, APP_SECRET); + + const response = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + }, + body: payload, + }); + + // 200 (success) or 401 (if using different app secret in prod) + expect([200, 401]).toContain(response.status); + }); + }); +}); + +describe("WhatsApp Cross-Platform Phone Mapping E2E", () => { + it("WhatsApp ID auto-derives phone number for linking", () => { + // This is a logic test (no server needed) verifying the derivation + const whatsappId = "14245071234"; + const derivedPhone = `+${whatsappId.replace(/\D/g, "")}`; + expect(derivedPhone).toBe("+14245071234"); + + // The same phone format used by Telegram/iMessage + expect(derivedPhone).toMatch(/^\+\d{7,15}$/); + }); + + it("different WhatsApp IDs produce different phone numbers", () => { + const wa1 = "14245071234"; + const wa2 = "14245075678"; + const phone1 = `+${wa1}`; + const phone2 = `+${wa2}`; + expect(phone1).not.toBe(phone2); + }); + + it("WhatsApp ID with country code maps correctly", () => { + // UK number + expect(`+${"447700900000"}`).toBe("+447700900000"); + // India number + expect(`+${"919876543210"}`).toBe("+919876543210"); + // US number + expect(`+${"14245074963"}`).toBe("+14245074963"); + }); +}); diff --git a/tests/unit/eliza-app/cross-platform-linking.test.ts b/tests/unit/eliza-app/cross-platform-linking.test.ts index 5a03886a3..0307b971c 100644 --- a/tests/unit/eliza-app/cross-platform-linking.test.ts +++ b/tests/unit/eliza-app/cross-platform-linking.test.ts @@ -31,6 +31,8 @@ interface User { discord_username: string | null; discord_global_name: string | null; discord_avatar_url: string | null; + whatsapp_id: string | null; + whatsapp_name: string | null; phone_number: string | null; phone_verified: boolean; name: string | null; @@ -92,6 +94,10 @@ function findOrgById(id: string): Organization | undefined { return organizations.find((o) => o.id === id); } +function findByWhatsAppId(whatsappId: string): User | undefined { + return users.find((u) => u.whatsapp_id === whatsappId); +} + function createUser(data: Partial & { name: string }): User { // Check unique constraints if (data.telegram_id && findByTelegramId(data.telegram_id)) { @@ -100,6 +106,9 @@ function createUser(data: Partial & { name: string }): User { if (data.discord_id && findByDiscordId(data.discord_id)) { throw new Error("unique constraint: discord_id"); } + if (data.whatsapp_id && findByWhatsAppId(data.whatsapp_id)) { + throw new Error("unique constraint: whatsapp_id"); + } if (data.phone_number && findByPhone(data.phone_number)) { throw new Error("unique constraint: phone_number"); } @@ -117,6 +126,8 @@ function createUser(data: Partial & { name: string }): User { discord_username: data.discord_username ?? null, discord_global_name: data.discord_global_name ?? null, discord_avatar_url: data.discord_avatar_url ?? null, + whatsapp_id: data.whatsapp_id ?? null, + whatsapp_name: data.whatsapp_name ?? null, phone_number: data.phone_number ?? null, phone_verified: data.phone_verified ?? false, name: data.name, @@ -139,6 +150,10 @@ function updateUser(id: string, data: Partial): User { const existing = findByDiscordId(data.discord_id); if (existing && existing.id !== id) throw new Error("unique constraint: discord_id"); } + if (data.whatsapp_id && data.whatsapp_id !== user.whatsapp_id) { + const existing = findByWhatsAppId(data.whatsapp_id); + if (existing && existing.id !== id) throw new Error("unique constraint: whatsapp_id"); + } if (data.phone_number && data.phone_number !== user.phone_number) { const existing = findByPhone(data.phone_number); if (existing && existing.id !== id) throw new Error("unique constraint: phone_number"); @@ -379,6 +394,80 @@ function linkPhoneToUser(userId: string, phoneNumber: string): LinkResult { } } +/** + * Simulates findOrCreateByWhatsAppId from user-service.ts + * 3-step lookup: whatsapp_id → phone (auto-derived) → create + */ +function findOrCreateByWhatsAppId( + whatsappId: string, + profileName?: string, +): FindOrCreateResult { + const derivedPhone = `+${whatsappId.replace(/\D/g, "")}`; + + // Step 1: Check by whatsapp_id + const existingWhatsApp = findByWhatsAppId(whatsappId); + if (existingWhatsApp) { + if (profileName && profileName !== existingWhatsApp.whatsapp_name) { + updateUser(existingWhatsApp.id, { whatsapp_name: profileName }); + } + const org = findOrgById(existingWhatsApp.organization_id)!; + return { user: existingWhatsApp, organization: org, isNew: false }; + } + + // Step 2: Check by auto-derived phone + const existingPhone = findByPhone(derivedPhone); + if (existingPhone) { + if (existingPhone.whatsapp_id && existingPhone.whatsapp_id !== whatsappId) { + throw new Error("WHATSAPP_ALREADY_LINKED"); + } + updateUser(existingPhone.id, { + whatsapp_id: whatsappId, + whatsapp_name: profileName ?? null, + }); + const org = findOrgById(existingPhone.organization_id)!; + return { user: existingPhone, organization: org, isNew: false }; + } + + // Step 3: Create new user + const displayName = profileName || `WhatsApp ***${whatsappId.slice(-4)}`; + const user = createUser({ + whatsapp_id: whatsappId, + whatsapp_name: profileName ?? null, + phone_number: derivedPhone, + phone_verified: true, + name: displayName, + }); + const org = findOrgById(user.organization_id)!; + return { user, organization: org, isNew: true }; +} + +/** + * Simulates linkWhatsAppToUser from user-service.ts (session-based linking) + */ +function linkWhatsAppToUser( + userId: string, + whatsappData: { whatsappId: string; name?: string }, +): LinkResult { + const existingWhatsApp = findByWhatsAppId(whatsappData.whatsappId); + + if (existingWhatsApp && existingWhatsApp.id !== userId) { + return { success: false, error: "This WhatsApp account is already linked to another account" }; + } + if (existingWhatsApp && existingWhatsApp.id === userId) { + return { success: true }; // Idempotent + } + + try { + updateUser(userId, { + whatsapp_id: whatsappData.whatsappId, + whatsapp_name: whatsappData.name ?? null, + }); + return { success: true }; + } catch { + return { success: false, error: "This WhatsApp account is already linked to another account" }; + } +} + // ============================================================================= // Test data constants // ============================================================================= @@ -1032,4 +1121,112 @@ describe("Cross-Platform Account Linking", () => { expect(result.error).toContain("already linked"); }); }); + + // =========================================================================== + // WhatsApp: findOrCreateByWhatsAppId + // =========================================================================== + + describe("findOrCreateByWhatsAppId", () => { + const WA_ID = "14245071234"; + const WA_NAME = "John WhatsApp"; + const WA_DERIVED_PHONE = "+14245071234"; + + test("creates new user with WhatsApp ID and auto-derived phone", () => { + const result = findOrCreateByWhatsAppId(WA_ID, WA_NAME); + expect(result.isNew).toBe(true); + expect(result.user.whatsapp_id).toBe(WA_ID); + expect(result.user.whatsapp_name).toBe(WA_NAME); + expect(result.user.phone_number).toBe(WA_DERIVED_PHONE); + expect(result.user.phone_verified).toBe(true); + }); + + test("returns existing user when WhatsApp ID already exists", () => { + const r1 = findOrCreateByWhatsAppId(WA_ID, WA_NAME); + const r2 = findOrCreateByWhatsAppId(WA_ID, "Updated Name"); + expect(r2.isNew).toBe(false); + expect(r2.user.id).toBe(r1.user.id); + expect(r2.user.whatsapp_name).toBe("Updated Name"); + }); + + test("links WhatsApp to existing phone user (Telegram-first)", () => { + const r1 = findOrCreateByTelegramWithPhone(TELEGRAM_USER, WA_DERIVED_PHONE); + expect(r1.user.whatsapp_id).toBeNull(); + + const r2 = findOrCreateByWhatsAppId(WA_ID, WA_NAME); + expect(r2.isNew).toBe(false); + expect(r2.user.id).toBe(r1.user.id); + expect(r2.user.whatsapp_id).toBe(WA_ID); + expect(r2.user.telegram_id).toBe(String(TELEGRAM_USER.id)); + expect(r2.user.phone_number).toBe(WA_DERIVED_PHONE); + }); + + test("links WhatsApp to existing phone user (iMessage-first)", () => { + const r1 = findOrCreateByPhone(WA_DERIVED_PHONE); + expect(r1.user.whatsapp_id).toBeNull(); + + const r2 = findOrCreateByWhatsAppId(WA_ID, WA_NAME); + expect(r2.isNew).toBe(false); + expect(r2.user.id).toBe(r1.user.id); + expect(r2.user.whatsapp_id).toBe(WA_ID); + }); + + test("all four platforms converge to single user", () => { + // Step 1: WhatsApp first + const r1 = findOrCreateByWhatsAppId(WA_ID, WA_NAME); + expect(r1.isNew).toBe(true); + + // Step 2: Telegram with same phone + const r2 = findOrCreateByTelegramWithPhone(TELEGRAM_USER, WA_DERIVED_PHONE); + expect(r2.isNew).toBe(false); + expect(r2.user.id).toBe(r1.user.id); + + // Step 3: Discord with same phone + const r3 = findOrCreateByDiscordId(DISCORD_ID, DISCORD_USER, WA_DERIVED_PHONE); + expect(r3.isNew).toBe(false); + expect(r3.user.id).toBe(r1.user.id); + + // Verify all platforms linked to single user + const finalUser = findById(r1.user.id)!; + expect(finalUser.whatsapp_id).toBe(WA_ID); + expect(finalUser.telegram_id).toBe(String(TELEGRAM_USER.id)); + expect(finalUser.discord_id).toBe(DISCORD_ID); + expect(finalUser.phone_number).toBe(WA_DERIVED_PHONE); + }); + }); + + // =========================================================================== + // linkWhatsAppToUser + // =========================================================================== + + describe("linkWhatsAppToUser", () => { + const WA_ID = "14245071234"; + + test("links WhatsApp to user with no WhatsApp", () => { + const r = findOrCreateByTelegramWithPhone(TELEGRAM_USER, PHONE); + const result = linkWhatsAppToUser(r.user.id, { + whatsappId: WA_ID, + name: "Test WA", + }); + expect(result.success).toBe(true); + expect(findById(r.user.id)!.whatsapp_id).toBe(WA_ID); + }); + + test("returns success when WhatsApp already linked to same user (idempotent)", () => { + const r = findOrCreateByWhatsAppId(WA_ID, "Test"); + const result = linkWhatsAppToUser(r.user.id, { + whatsappId: WA_ID, + }); + expect(result.success).toBe(true); + }); + + test("returns error when WhatsApp linked to different user", () => { + findOrCreateByWhatsAppId(WA_ID, "User A"); + const r2 = findOrCreateByTelegramWithPhone(TELEGRAM_USER, PHONE); + const result = linkWhatsAppToUser(r2.user.id, { + whatsappId: WA_ID, + }); + expect(result.success).toBe(false); + expect(result.error).toContain("already linked"); + }); + }); }); diff --git a/tests/unit/eliza-app/whatsapp-auth.test.ts b/tests/unit/eliza-app/whatsapp-auth.test.ts new file mode 100644 index 000000000..da648f8fc --- /dev/null +++ b/tests/unit/eliza-app/whatsapp-auth.test.ts @@ -0,0 +1,111 @@ +/** + * WhatsApp Auth Service Tests + * + * Tests HMAC-SHA256 webhook signature verification: + * - Valid signature acceptance + * - Invalid/tampered signature rejection + * - Missing App Secret handling + * - Webhook subscription verification (GET handshake) + */ + +import { describe, test, expect } from "bun:test"; +import { createHmac } from "crypto"; +import { verifyWhatsAppSignature } from "../../lib/utils/whatsapp-api"; + +const TEST_APP_SECRET = "test_whatsapp_app_secret_abc123"; +const TEST_VERIFY_TOKEN = "my_verify_token_xyz"; + +function makeSignature(body: string, secret: string): string { + return "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("WhatsApp Webhook Signature Verification", () => { + test("accepts valid signature", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const sig = makeSignature(body, TEST_APP_SECRET); + expect(verifyWhatsAppSignature(TEST_APP_SECRET, sig, body)).toBe(true); + }); + + test("rejects signature with wrong secret", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const sig = makeSignature(body, "wrong_secret"); + expect(verifyWhatsAppSignature(TEST_APP_SECRET, sig, body)).toBe(false); + }); + + test("rejects tampered payload", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const sig = makeSignature(body, TEST_APP_SECRET); + expect(verifyWhatsAppSignature(TEST_APP_SECRET, sig, body + "x")).toBe(false); + }); + + test("rejects empty signature header", () => { + const body = '{"test":true}'; + expect(verifyWhatsAppSignature(TEST_APP_SECRET, "", body)).toBe(false); + }); + + test("rejects empty app secret", () => { + const body = '{"test":true}'; + const sig = makeSignature(body, TEST_APP_SECRET); + expect(verifyWhatsAppSignature("", sig, body)).toBe(false); + }); + + test("handles large payloads", () => { + const body = JSON.stringify({ data: "x".repeat(100000) }); + const sig = makeSignature(body, TEST_APP_SECRET); + expect(verifyWhatsAppSignature(TEST_APP_SECRET, sig, body)).toBe(true); + }); + + test("handles unicode in payload", () => { + const body = JSON.stringify({ text: "Hello! こんにちは 🎉" }); + const sig = makeSignature(body, TEST_APP_SECRET); + expect(verifyWhatsAppSignature(TEST_APP_SECRET, sig, body)).toBe(true); + }); + + test("timing-safe comparison prevents length-based detection", () => { + const body = '{"test":true}'; + // Create a signature that differs in length by having non-hex chars + expect(verifyWhatsAppSignature(TEST_APP_SECRET, "sha256=short", body)).toBe(false); + }); +}); + +describe("WhatsApp Webhook Subscription Verification Logic", () => { + // These tests verify the logic without importing the service (which needs config) + + test("verify_token match logic", () => { + const expectedToken = TEST_VERIFY_TOKEN; + const mode = "subscribe"; + const verifyToken = TEST_VERIFY_TOKEN; + const challenge = "1234567890"; + + // Simulating the auth service logic + const isValid = mode === "subscribe" && verifyToken === expectedToken && !!challenge; + expect(isValid).toBe(true); + }); + + test("rejects wrong mode", () => { + const mode = "unsubscribe"; + const verifyToken = TEST_VERIFY_TOKEN; + const challenge = "1234567890"; + + const isValid = mode === "subscribe" && verifyToken === TEST_VERIFY_TOKEN && !!challenge; + expect(isValid).toBe(false); + }); + + test("rejects wrong verify token", () => { + const mode = "subscribe"; + const verifyToken = "wrong_token"; + const challenge = "1234567890"; + + const isValid = mode === "subscribe" && verifyToken === TEST_VERIFY_TOKEN && !!challenge; + expect(isValid).toBe(false); + }); + + test("rejects missing challenge", () => { + const mode = "subscribe"; + const verifyToken = TEST_VERIFY_TOKEN; + const challenge = ""; + + const isValid = mode === "subscribe" && verifyToken === TEST_VERIFY_TOKEN && !!challenge; + expect(isValid).toBe(false); + }); +}); diff --git a/tests/unit/eliza-app/whatsapp-webhook.test.ts b/tests/unit/eliza-app/whatsapp-webhook.test.ts new file mode 100644 index 000000000..6bb3408bb --- /dev/null +++ b/tests/unit/eliza-app/whatsapp-webhook.test.ts @@ -0,0 +1,194 @@ +/** + * WhatsApp Webhook Handler Tests + * + * Tests the webhook verification GET handler and POST handler logic: + * - GET: hub.mode, hub.verify_token, hub.challenge verification + * - POST: Signature verification, payload parsing, message extraction + * - Idempotency: Duplicate message handling + */ + +import { describe, test, expect } from "bun:test"; +import { createHmac } from "crypto"; +import { + verifyWhatsAppSignature, + parseWhatsAppWebhookPayload, + extractWhatsAppMessages, + type WhatsAppWebhookPayload, +} from "../../../lib/utils/whatsapp-api"; + +const APP_SECRET = "test_webhook_app_secret"; + +describe("WhatsApp Webhook GET (Verification Handshake)", () => { + const VERIFY_TOKEN = "my_custom_verify_token"; + + function simulateVerification( + mode: string | null, + verifyToken: string | null, + challenge: string | null, + expectedToken: string, + ): string | null { + if (mode !== "subscribe") return null; + if (verifyToken !== expectedToken) return null; + if (!challenge) return null; + return challenge; + } + + test("returns challenge when verification succeeds", () => { + const result = simulateVerification("subscribe", VERIFY_TOKEN, "123456", VERIFY_TOKEN); + expect(result).toBe("123456"); + }); + + test("returns null for wrong mode", () => { + const result = simulateVerification("unsubscribe", VERIFY_TOKEN, "123456", VERIFY_TOKEN); + expect(result).toBeNull(); + }); + + test("returns null for wrong verify token", () => { + const result = simulateVerification("subscribe", "wrong_token", "123456", VERIFY_TOKEN); + expect(result).toBeNull(); + }); + + test("returns null for missing challenge", () => { + const result = simulateVerification("subscribe", VERIFY_TOKEN, null, VERIFY_TOKEN); + expect(result).toBeNull(); + }); + + test("returns null for null mode", () => { + const result = simulateVerification(null, VERIFY_TOKEN, "123456", VERIFY_TOKEN); + expect(result).toBeNull(); + }); +}); + +describe("WhatsApp Webhook POST (Message Processing)", () => { + function makeSignature(body: string): string { + return "sha256=" + createHmac("sha256", APP_SECRET).update(body).digest("hex"); + } + + test("validates signature and extracts messages", () => { + const payload = { + object: "whatsapp_business_account", + entry: [{ + id: "biz_account_id", + changes: [{ + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_123", + }, + contacts: [{ profile: { name: "Test User" }, wa_id: "14155551234" }], + messages: [{ + id: "wamid.test123", + from: "14155551234", + timestamp: "1706745600", + type: "text", + text: { body: "Hello!" }, + }], + }, + field: "messages", + }], + }], + }; + + const body = JSON.stringify(payload); + const signature = makeSignature(body); + + // Step 1: Verify signature + expect(verifyWhatsAppSignature(APP_SECRET, signature, body)).toBe(true); + + // Step 2: Parse payload + const parsed = parseWhatsAppWebhookPayload(payload); + expect(parsed.object).toBe("whatsapp_business_account"); + + // Step 3: Extract messages + const messages = extractWhatsAppMessages(parsed); + expect(messages).toHaveLength(1); + expect(messages[0].text).toBe("Hello!"); + expect(messages[0].from).toBe("14155551234"); + expect(messages[0].profileName).toBe("Test User"); + }); + + test("rejects message with invalid signature", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const fakeSignature = "sha256=0000000000000000000000000000000000000000000000000000000000000000"; + expect(verifyWhatsAppSignature(APP_SECRET, fakeSignature, body)).toBe(false); + }); + + test("handles payload with no messages (status update)", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [{ + id: "biz_account_id", + changes: [{ + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_123", + }, + statuses: [{ + id: "wamid.test123", + status: "delivered", + timestamp: "1706745600", + recipient_id: "14155551234", + }], + }, + field: "messages", + }], + }], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(0); + }); + + test("extracts image message (type but no text)", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [{ + id: "biz_account_id", + changes: [{ + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_123", + }, + contacts: [{ profile: { name: "Photo User" }, wa_id: "14155551234" }], + messages: [{ + id: "wamid.img123", + from: "14155551234", + timestamp: "1706745600", + type: "image", + image: { + id: "img_media_id", + mime_type: "image/jpeg", + }, + }], + }, + field: "messages", + }], + }], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("image"); + expect(messages[0].text).toBeUndefined(); + expect(messages[0].profileName).toBe("Photo User"); + }); +}); + +describe("WhatsApp Idempotency Key Generation", () => { + test("generates consistent idempotency key from message ID", () => { + const messageId = "wamid.HBgLMTQyNDUwNzQ5NjM="; + const key = `whatsapp:eliza-app:${messageId}`; + expect(key).toBe(`whatsapp:eliza-app:${messageId}`); + }); + + test("different messages produce different keys", () => { + const key1 = `whatsapp:eliza-app:wamid.msg1`; + const key2 = `whatsapp:eliza-app:wamid.msg2`; + expect(key1).not.toBe(key2); + }); +}); diff --git a/tests/unit/message-router-service.test.ts b/tests/unit/message-router-service.test.ts index f35d98dc6..a2de29fbb 100644 --- a/tests/unit/message-router-service.test.ts +++ b/tests/unit/message-router-service.test.ts @@ -160,6 +160,18 @@ describe("MessageRouterService", () => { expect(result).toHaveProperty("success"); }); + + it("handles WhatsApp message type", async () => { + const result = await messageRouterService.routeIncomingMessage({ + from: "14245071234", + to: "+14245074963", + body: "WhatsApp message", + provider: "whatsapp", + messageType: "whatsapp", + }); + + expect(result).toHaveProperty("success"); + }); }); describe("sendMessage", () => { @@ -202,6 +214,19 @@ describe("MessageRouterService", () => { expect(result).toBe(false); }); + it("handles WhatsApp provider (fails without credentials)", async () => { + const result = await messageRouterService.sendMessage({ + to: "14245071234", + from: "+14245074963", + body: "Test WhatsApp message", + provider: "whatsapp", + organizationId: testOrgId, + }); + + // Will fail due to missing credentials + expect(result).toBe(false); + }); + it("handles message with media URLs", async () => { const result = await messageRouterService.sendMessage({ to: "+15551234567", diff --git a/tests/unit/whatsapp-api-util.test.ts b/tests/unit/whatsapp-api-util.test.ts new file mode 100644 index 000000000..e74e7ce07 --- /dev/null +++ b/tests/unit/whatsapp-api-util.test.ts @@ -0,0 +1,390 @@ +/** + * WhatsApp API Utility Tests + * + * Tests for: + * - Webhook signature verification (HMAC-SHA256) + * - Webhook payload parsing and validation + * - Message extraction from nested payloads + * - Phone number formatting (WhatsApp ID <-> E.164) + * - WhatsApp ID validation + */ + +import { describe, test, expect } from "bun:test"; +import { createHmac } from "crypto"; +import { + verifyWhatsAppSignature, + parseWhatsAppWebhookPayload, + extractWhatsAppMessages, + whatsappIdToE164, + e164ToWhatsappId, + isValidWhatsAppId, + type WhatsAppWebhookPayload, +} from "../../lib/utils/whatsapp-api"; + +// ============================================================================ +// Webhook Signature Verification +// ============================================================================ + +describe("WhatsApp Webhook Signature Verification", () => { + const APP_SECRET = "test_app_secret_12345"; + + function generateSignature(body: string, secret: string): string { + return "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); + } + + test("accepts valid signature", () => { + const body = JSON.stringify({ object: "whatsapp_business_account", entry: [] }); + const signature = generateSignature(body, APP_SECRET); + expect(verifyWhatsAppSignature(APP_SECRET, signature, body)).toBe(true); + }); + + test("rejects invalid signature", () => { + const body = JSON.stringify({ object: "whatsapp_business_account", entry: [] }); + const signature = "sha256=0000000000000000000000000000000000000000000000000000000000000000"; + expect(verifyWhatsAppSignature(APP_SECRET, signature, body)).toBe(false); + }); + + test("rejects tampered body", () => { + const body = JSON.stringify({ object: "whatsapp_business_account", entry: [] }); + const signature = generateSignature(body, APP_SECRET); + const tamperedBody = body + "tampered"; + expect(verifyWhatsAppSignature(APP_SECRET, signature, tamperedBody)).toBe(false); + }); + + test("rejects wrong app secret", () => { + const body = JSON.stringify({ object: "whatsapp_business_account" }); + const signature = generateSignature(body, APP_SECRET); + expect(verifyWhatsAppSignature("wrong_secret", signature, body)).toBe(false); + }); + + test("rejects empty signature header", () => { + const body = JSON.stringify({ object: "whatsapp_business_account" }); + expect(verifyWhatsAppSignature(APP_SECRET, "", body)).toBe(false); + }); + + test("rejects empty app secret", () => { + const body = JSON.stringify({ object: "whatsapp_business_account" }); + const signature = generateSignature(body, APP_SECRET); + expect(verifyWhatsAppSignature("", signature, body)).toBe(false); + }); + + test("rejects signature without sha256= prefix", () => { + const body = JSON.stringify({ object: "whatsapp_business_account" }); + const rawHash = createHmac("sha256", APP_SECRET).update(body).digest("hex"); + // Without the "sha256=" prefix, the hex should not match + expect(verifyWhatsAppSignature(APP_SECRET, rawHash, body)).toBe(false); + }); +}); + +// ============================================================================ +// Webhook Payload Parsing +// ============================================================================ + +describe("WhatsApp Webhook Payload Parsing", () => { + const validPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + contacts: [ + { + profile: { name: "John Doe" }, + wa_id: "14245071234", + }, + ], + messages: [ + { + id: "wamid.abc123", + from: "14245071234", + timestamp: "1706745600", + type: "text", + text: { body: "Hello Eliza!" }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }; + + test("parses valid payload", () => { + const result = parseWhatsAppWebhookPayload(validPayload); + expect(result.object).toBe("whatsapp_business_account"); + expect(result.entry).toHaveLength(1); + expect(result.entry[0].changes[0].value.messaging_product).toBe("whatsapp"); + }); + + test("rejects payload with wrong object type", () => { + expect(() => + parseWhatsAppWebhookPayload({ ...validPayload, object: "page" }) + ).toThrow(); + }); + + test("rejects payload with missing entry", () => { + expect(() => + parseWhatsAppWebhookPayload({ object: "whatsapp_business_account" }) + ).toThrow(); + }); + + test("rejects invalid JSON structure", () => { + expect(() => parseWhatsAppWebhookPayload("not an object")).toThrow(); + expect(() => parseWhatsAppWebhookPayload(null)).toThrow(); + expect(() => parseWhatsAppWebhookPayload(123)).toThrow(); + }); + + test("parses payload with status updates (no messages)", () => { + const statusPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + statuses: [ + { + id: "wamid.abc123", + status: "delivered", + timestamp: "1706745600", + recipient_id: "14245071234", + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }; + + const result = parseWhatsAppWebhookPayload(statusPayload); + expect(result.entry[0].changes[0].value.statuses).toHaveLength(1); + }); +}); + +// ============================================================================ +// Message Extraction +// ============================================================================ + +describe("WhatsApp Message Extraction", () => { + test("extracts text message with profile name", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + contacts: [ + { + profile: { name: "John Doe" }, + wa_id: "14245071234", + }, + ], + messages: [ + { + id: "wamid.abc123", + from: "14245071234", + timestamp: "1706745600", + type: "text", + text: { body: "Hello Eliza!" }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(1); + expect(messages[0].messageId).toBe("wamid.abc123"); + expect(messages[0].from).toBe("14245071234"); + expect(messages[0].text).toBe("Hello Eliza!"); + expect(messages[0].profileName).toBe("John Doe"); + expect(messages[0].phoneNumberId).toBe("phone_id_123"); + expect(messages[0].type).toBe("text"); + }); + + test("returns empty array for status-only payloads", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + statuses: [ + { + id: "wamid.abc123", + status: "delivered", + timestamp: "1706745600", + recipient_id: "14245071234", + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(0); + }); + + test("extracts multiple messages", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + contacts: [ + { profile: { name: "User A" }, wa_id: "14245071111" }, + ], + messages: [ + { + id: "wamid.msg1", + from: "14245071111", + timestamp: "1706745600", + type: "text", + text: { body: "First message" }, + }, + { + id: "wamid.msg2", + from: "14245071111", + timestamp: "1706745601", + type: "text", + text: { body: "Second message" }, + }, + ], + }, + field: "messages", + }, + ], + }, + ], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(2); + expect(messages[0].text).toBe("First message"); + expect(messages[1].text).toBe("Second message"); + }); + + test("skips non-messages field changes", () => { + const payload: WhatsAppWebhookPayload = { + object: "whatsapp_business_account", + entry: [ + { + id: "123456789", + changes: [ + { + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+14245074963", + phone_number_id: "phone_id_123", + }, + }, + field: "account_update", + }, + ], + }, + ], + }; + + const messages = extractWhatsAppMessages(payload); + expect(messages).toHaveLength(0); + }); +}); + +// ============================================================================ +// Phone Number Formatting +// ============================================================================ + +describe("WhatsApp Phone Number Formatting", () => { + test("whatsappIdToE164 adds + prefix", () => { + expect(whatsappIdToE164("14245074963")).toBe("+14245074963"); + expect(whatsappIdToE164("447700900000")).toBe("+447700900000"); + }); + + test("whatsappIdToE164 strips non-digit characters", () => { + expect(whatsappIdToE164("+14245074963")).toBe("+14245074963"); + expect(whatsappIdToE164("1-424-507-4963")).toBe("+14245074963"); + }); + + test("e164ToWhatsappId removes + prefix", () => { + expect(e164ToWhatsappId("+14245074963")).toBe("14245074963"); + expect(e164ToWhatsappId("+447700900000")).toBe("447700900000"); + }); + + test("e164ToWhatsappId handles already-clean IDs", () => { + expect(e164ToWhatsappId("14245074963")).toBe("14245074963"); + }); + + test("roundtrip conversion", () => { + const originalId = "14245074963"; + expect(e164ToWhatsappId(whatsappIdToE164(originalId))).toBe(originalId); + }); +}); + +// ============================================================================ +// WhatsApp ID Validation +// ============================================================================ + +describe("WhatsApp ID Validation", () => { + test("accepts valid WhatsApp IDs", () => { + expect(isValidWhatsAppId("14245074963")).toBe(true); // US number + expect(isValidWhatsAppId("447700900000")).toBe(true); // UK number + expect(isValidWhatsAppId("8613800138000")).toBe(true); // China number + expect(isValidWhatsAppId("1234567")).toBe(true); // Minimum length + }); + + test("rejects invalid WhatsApp IDs", () => { + expect(isValidWhatsAppId("")).toBe(false); // Empty + expect(isValidWhatsAppId("123456")).toBe(false); // Too short (6 digits) + expect(isValidWhatsAppId("1234567890123456")).toBe(false); // Too long (16 digits) + expect(isValidWhatsAppId("+14245074963")).toBe(false); // Has + prefix + expect(isValidWhatsAppId("1-424-507-4963")).toBe(false); // Has dashes + expect(isValidWhatsAppId("abc1234567")).toBe(false); // Has letters + }); +}); diff --git a/tests/unit/whatsapp-automation.test.ts b/tests/unit/whatsapp-automation.test.ts new file mode 100644 index 000000000..518b3fbee --- /dev/null +++ b/tests/unit/whatsapp-automation.test.ts @@ -0,0 +1,221 @@ +/** + * WhatsApp Automation Service Tests + * + * Tests the organization-level WhatsApp integration service: + * - Verify token generation + * - Access token validation logic + * - Webhook subscription verification + * - Webhook signature verification delegation + * - Connection status logic + */ + +import { describe, test, expect } from "bun:test"; +import crypto from "crypto"; +import { verifyWhatsAppSignature, isValidWhatsAppId } from "../../lib/utils/whatsapp-api"; + +// ============================================================================ +// Verify Token Generation +// ============================================================================ + +describe("WhatsApp Automation - Verify Token Generation", () => { + test("generates a verify token with correct prefix", () => { + // Simulate the generateVerifyToken logic + const token = `wa_verify_${crypto.randomBytes(24).toString("hex")}`; + expect(token).toStartWith("wa_verify_"); + expect(token.length).toBeGreaterThan(20); + }); + + test("generates unique tokens on each call", () => { + const token1 = `wa_verify_${crypto.randomBytes(24).toString("hex")}`; + const token2 = `wa_verify_${crypto.randomBytes(24).toString("hex")}`; + expect(token1).not.toBe(token2); + }); + + test("token is hex-safe (no special characters)", () => { + const token = `wa_verify_${crypto.randomBytes(24).toString("hex")}`; + // Should only contain alphanumeric + underscore + expect(/^[a-zA-Z0-9_]+$/.test(token)).toBe(true); + }); +}); + +// ============================================================================ +// Webhook Subscription Verification Logic +// ============================================================================ + +describe("WhatsApp Automation - Webhook Subscription Verification", () => { + const STORED_TOKEN = "wa_verify_abc123def456"; + + // Simulates the verifyWebhookSubscription logic + function verifySubscription( + mode: string | null, + verifyToken: string | null, + challenge: string | null, + storedToken: string | null, + ): string | null { + if (mode !== "subscribe" || !verifyToken || !challenge) { + return null; + } + if (!storedToken || verifyToken !== storedToken) { + return null; + } + return challenge; + } + + test("returns challenge when all params match", () => { + expect( + verifySubscription("subscribe", STORED_TOKEN, "12345", STORED_TOKEN), + ).toBe("12345"); + }); + + test("rejects when mode is not subscribe", () => { + expect( + verifySubscription("unsubscribe", STORED_TOKEN, "12345", STORED_TOKEN), + ).toBeNull(); + }); + + test("rejects when verify token does not match", () => { + expect( + verifySubscription("subscribe", "wrong_token", "12345", STORED_TOKEN), + ).toBeNull(); + }); + + test("rejects when challenge is missing", () => { + expect( + verifySubscription("subscribe", STORED_TOKEN, null, STORED_TOKEN), + ).toBeNull(); + }); + + test("rejects when mode is null", () => { + expect( + verifySubscription(null, STORED_TOKEN, "12345", STORED_TOKEN), + ).toBeNull(); + }); + + test("rejects when no stored token exists", () => { + expect( + verifySubscription("subscribe", STORED_TOKEN, "12345", null), + ).toBeNull(); + }); +}); + +// ============================================================================ +// Webhook Signature Verification (delegates to whatsapp-api util) +// ============================================================================ + +describe("WhatsApp Automation - Signature Verification Delegation", () => { + const APP_SECRET = "org_specific_app_secret_123"; + + function makeSignature(body: string, secret: string): string { + return ( + "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex") + ); + } + + test("accepts valid signature with org-specific secret", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const sig = makeSignature(body, APP_SECRET); + expect(verifyWhatsAppSignature(APP_SECRET, sig, body)).toBe(true); + }); + + test("rejects signature from different org secret", () => { + const body = '{"object":"whatsapp_business_account","entry":[]}'; + const sig = makeSignature(body, "other_org_secret"); + expect(verifyWhatsAppSignature(APP_SECRET, sig, body)).toBe(false); + }); +}); + +// ============================================================================ +// WhatsApp ID Validation +// ============================================================================ + +describe("WhatsApp Automation - ID Validation in Webhook", () => { + test("accepts valid WhatsApp IDs", () => { + expect(isValidWhatsAppId("14245074963")).toBe(true); + expect(isValidWhatsAppId("491511234567")).toBe(true); + expect(isValidWhatsAppId("1234567")).toBe(true); // 7 digits minimum + expect(isValidWhatsAppId("123456789012345")).toBe(true); // 15 digits maximum + }); + + test("rejects IDs with non-digit characters", () => { + expect(isValidWhatsAppId("+14245074963")).toBe(false); + expect(isValidWhatsAppId("1424-507-4963")).toBe(false); + expect(isValidWhatsAppId("abc")).toBe(false); + expect(isValidWhatsAppId("14245abc963")).toBe(false); + }); + + test("rejects IDs that are too short or too long", () => { + expect(isValidWhatsAppId("123456")).toBe(false); // 6 digits + expect(isValidWhatsAppId("1234567890123456")).toBe(false); // 16 digits + expect(isValidWhatsAppId("")).toBe(false); + }); +}); + +// ============================================================================ +// Connection Status Logic +// ============================================================================ + +describe("WhatsApp Automation - Connection Status Logic", () => { + test("not configured when no credentials", () => { + const accessToken = null; + const phoneNumberId = null; + + const configured = !!(accessToken && phoneNumberId); + expect(configured).toBe(false); + }); + + test("configured when both token and phone number ID exist", () => { + const accessToken = "EAAxxxxxxxx"; + const phoneNumberId = "123456789"; + + const configured = !!(accessToken && phoneNumberId); + expect(configured).toBe(true); + }); + + test("not fully configured when only token exists", () => { + const accessToken = "EAAxxxxxxxx"; + const phoneNumberId = null; + + const configured = !!(accessToken && phoneNumberId); + expect(configured).toBe(false); + }); +}); + +// ============================================================================ +// Idempotency Key Format +// ============================================================================ + +describe("WhatsApp Automation - Idempotency Key Format", () => { + test("org-level key includes orgId for isolation", () => { + const orgId = "org_123"; + const messageId = "wamid.abc123"; + const key = `whatsapp:org:${orgId}:${messageId}`; + + expect(key).toBe("whatsapp:org:org_123:wamid.abc123"); + expect(key).toContain(orgId); + expect(key).toContain(messageId); + }); + + test("different orgs produce different keys for same message", () => { + const messageId = "wamid.abc123"; + const key1 = `whatsapp:org:org_1:${messageId}`; + const key2 = `whatsapp:org:org_2:${messageId}`; + + expect(key1).not.toBe(key2); + }); +}); + +// ============================================================================ +// Secret Names +// ============================================================================ + +describe("WhatsApp Automation - Secret Name Constants", () => { + test("secret names are correctly defined", async () => { + const { SECRET_NAMES } = await import("../../lib/constants/secrets"); + + expect(SECRET_NAMES.WHATSAPP.ACCESS_TOKEN).toBe("WHATSAPP_ACCESS_TOKEN"); + expect(SECRET_NAMES.WHATSAPP.PHONE_NUMBER_ID).toBe("WHATSAPP_PHONE_NUMBER_ID"); + expect(SECRET_NAMES.WHATSAPP.APP_SECRET).toBe("WHATSAPP_APP_SECRET"); + expect(SECRET_NAMES.WHATSAPP.VERIFY_TOKEN).toBe("WHATSAPP_VERIFY_TOKEN"); + expect(SECRET_NAMES.WHATSAPP.BUSINESS_PHONE).toBe("WHATSAPP_BUSINESS_PHONE"); + }); +});