import { z } from 'zod'; import { ToolSet } from 'ai'; import { ArAgentsError } from '@ar-agents/core'; /** * The phone number ID assigned by Meta to a WhatsApp Business Account number. * NOT the user-visible phone number — it's a numeric ID like `123456789012345`. * Find it in Meta Business Suite → WhatsApp → API Setup. */ type PhoneNumberId = string; /** * Recipient phone number in E.164 format WITHOUT the leading `+`. Examples: * `5491112345678` (Argentina), `14155552671` (US). The `normalizeArPhone` * helper accepts AR variants and produces this shape. */ type WhatsAppRecipient = string; /** A WhatsApp message ID returned by the Cloud API after a successful send. */ type WhatsAppMessageId = string; /** Result of any send_* tool — the wamid plus echoed recipient/timestamp. */ declare const SendResultSchema: z.ZodObject<{ messageId: z.ZodString; recipient: z.ZodString; timestamp: z.ZodOptional; }, z.core.$strip>; type SendResult = z.infer; /** Inbound webhook event — normalized from Meta's nested envelope. */ type WebhookEvent = InboundMessageEvent | StatusUpdateEvent | UnknownEvent; interface InboundMessageEvent { kind: "message"; /** Phone number ID that received the message (your WABA number). */ phoneNumberId: PhoneNumberId; /** Sender's phone in E.164 (no +). */ from: WhatsAppRecipient; /** Display name of the sender (set in their WhatsApp profile). */ fromName: string | null; /** Meta-issued message ID. */ messageId: WhatsAppMessageId; /** Unix epoch seconds, as a string (Meta's format). */ timestamp: string; /** Message body — discriminated by type. */ message: { type: "text"; text: string; } | { type: "image"; mediaId: string; caption: string | null; mimeType: string | null; } | { type: "audio"; mediaId: string; mimeType: string | null; voice: boolean; } | { type: "video"; mediaId: string; caption: string | null; mimeType: string | null; } | { type: "document"; mediaId: string; filename: string | null; mimeType: string | null; } | { type: "location"; latitude: number; longitude: number; name: string | null; address: string | null; } | { type: "contacts"; raw: unknown; } | { type: "interactive"; subtype: "button_reply" | "list_reply"; id: string; title: string; } | { type: "button"; payload: string; text: string; } | { type: "reaction"; emoji: string; targetMessageId: string; } | { type: "unsupported"; raw: unknown; }; /** If this message is a reply, the wamid of the message it replies to. */ contextMessageId: string | null; } interface StatusUpdateEvent { kind: "status"; phoneNumberId: PhoneNumberId; /** wamid of the original outbound message this status refers to. */ messageId: WhatsAppMessageId; /** sent → delivered → read → failed. */ status: "sent" | "delivered" | "read" | "failed" | "warning"; recipient: WhatsAppRecipient; timestamp: string; /** When status === "failed", structured error info from Meta. */ errors: Array<{ code: number; title: string; details: string | null; }>; } interface UnknownEvent { kind: "unknown"; raw: unknown; } /** Configuration accepted by `WhatsAppClient`. */ interface WhatsAppClientOptions { /** Permanent or system-user access token from Meta. Required. */ accessToken: string; /** WABA phone number ID (numeric string from Meta dashboard). Required. */ phoneNumberId: PhoneNumberId; /** * Escape hatch for browser-context tests (e.g., jsdom). MUST NOT be set * in production code — the constructor's browser-context check exists * specifically to prevent the Meta access token from being bundled into * a client-side JavaScript bundle. */ __allowBrowser?: boolean; /** Graph API version. Default `"v21.0"`. */ apiVersion?: string; /** Override Graph base URL (testing). */ baseUrl?: string; /** Custom fetch (testing). */ fetchImpl?: typeof fetch; /** * Per-request timeout in ms. Aborts via AbortSignal and throws if exceeded. * Default 30_000 (30s). */ requestTimeoutMs?: number; /** * Number of retries on 5xx + network errors. Default 1. 4xx never retried * (Meta's user/config errors). Exponential backoff starting at 250ms. */ maxRetries?: number; /** * Observability hook fired AFTER every request (success or failure). * Synchronous, fire-and-forget — useful for logging / metrics / tracing. */ onCall?: (event: { method: string; path: string; durationMs: number; httpStatus: number | null; retried: number; success: boolean; }) => void; } declare class WhatsAppClient { private readonly accessToken; private readonly phoneNumberId; private readonly apiVersion; private readonly baseUrl; private readonly fetchImpl; private readonly requestTimeoutMs; private readonly maxRetries; private readonly onCall; constructor(options: WhatsAppClientOptions); /** * Internal helper: fetch with timeout + retry on 5xx/network errors. * 4xx never retried. * * Retries only apply to idempotent requests. The Meta Cloud API has no * client-supplied idempotency key on `POST /messages`, so retrying a send * that timed out or 5xx'd risks delivering the message more than once. * Callers issuing non-idempotent requests MUST pass `{ retry: false }`. */ private fetchWithRetry; /** * Send a plain text message. Only works within the 24-hour customer service * window (Meta restriction). Outside the window, use `sendTemplate`. * * @example * ```ts * await wa.sendText({ * to: "5491112345678", * text: "Hola Lautaro, tu pedido ya salió.", * }); * ``` */ sendText(params: { to: WhatsAppRecipient; text: string; /** * Enable URL preview cards. Default `true` to match WhatsApp UX * expectations (links render as previews automatically). */ previewUrl?: boolean; /** wamid of the message to reply to (creates threaded reply). */ contextMessageId?: string; }): Promise; /** * Send a pre-approved template message. Required for messages outside the * 24-hour window (transactional, marketing, utility). * * Templates must be created and approved in Meta Business Suite first. * * @example * ```ts * await wa.sendTemplate({ * to: "5491112345678", * templateName: "order_shipped", * languageCode: "es_AR", * bodyParams: ["Lautaro", "OL-12345"], * }); * ``` */ sendTemplate(params: { to: WhatsAppRecipient; /** Exact template name as registered in Meta. */ templateName: string; /** Language code, e.g., "es_AR", "es", "en_US". */ languageCode?: string; /** Positional parameters for the template body, in order. */ bodyParams?: string[]; /** Optional positional parameters for the header (text or media). */ headerParams?: Array<{ type: "text"; text: string; } | { type: "image"; link: string; } | { type: "document"; link: string; filename?: string; } | { type: "video"; link: string; }>; }): Promise; /** * Send media (image, audio, video, document). Provide either a public URL * (`link`) OR a pre-uploaded `mediaId` (from `uploadMedia`). * * @example * ```ts * await wa.sendMedia({ * to: "5491112345678", * type: "image", * link: "https://example.com/order-receipt.jpg", * caption: "Tu comprobante", * }); * ``` */ sendMedia(params: { to: WhatsAppRecipient; type: "image" | "audio" | "video" | "document" | "sticker"; /** Public URL — Meta downloads and sends. */ link?: string; /** Pre-uploaded media ID from `uploadMedia`. */ mediaId?: string; /** Caption (image/video/document only). */ caption?: string; /** Filename hint (document only). */ filename?: string; }): Promise; /** * Send an interactive message: buttons (up to 3 reply buttons) or a list * picker (sectioned menu). Useful for confirming actions in chat without * forcing the user to type. */ sendInteractive(params: { to: WhatsAppRecipient; bodyText: string; headerText?: string; footerText?: string; /** Choose buttons OR list, not both. */ buttons?: Array<{ id: string; title: string; }>; list?: { buttonText: string; sections: Array<{ title?: string; rows: Array<{ id: string; title: string; description?: string; }>; }>; }; }): Promise; /** * Mark an inbound message as read (sends the blue double-check). Call this * from your webhook handler when you process the user's message. */ markAsRead(messageId: string): Promise; /** * Download a media file the user sent. Two-step: GET the media metadata * (returns a URL valid for ~5 minutes), then GET the URL with auth. */ downloadMedia(mediaId: string): Promise<{ bytes: ArrayBuffer; mimeType: string | null; filename: string | null; sha256: string | null; }>; /** * Internal: POST to /{phoneNumberId}/messages. Throws typed errors on 4xx. * Uses fetchWithRetry with retries DISABLED: `POST /messages` is not * idempotent and Meta exposes no idempotency key, so a retry after a timeout * or 5xx could deliver the same message twice. Better to surface the error * and let the caller decide than to risk a duplicate send. */ private postMessages; private toApiError; private toSendResult; } /** * Build the WhatsApp tool set for a Vercel AI SDK agent. * * Wires the methods of `WhatsAppClient` into named, schema-validated tools * that the LLM can invoke. The tool descriptions are written for the LLM - * they explain when to use each tool and when NOT to. * * # Scoped mode (recommended for webhook handlers) * * Pass `options.scopedTo: senderPhone` to bind every outbound message to the * inbound sender. The `to` parameter is REMOVED from the tool schemas so the * LLM cannot message a different number. This prevents agent-hijacking * scenarios where a crafted inbound message tricks the agent into spamming * arbitrary recipients (security audit finding /cso F5). * * # Unscoped mode * * Without `scopedTo`, tools accept arbitrary `to` arguments. Suitable for * batch/proactive flows where the agent is sending notifications to a list * of recipients you control. * * @example Scoped (webhook handler, recommended) * ```ts * import { whatsappTools, WhatsAppClient } from "@ar-agents/whatsapp"; * * const wa = new WhatsAppClient({ ... }); * const event = parseWebhookEvent(payload); * * // All send_* tools auto-bind `to` to event.from. LLM cannot override. * const tools = whatsappTools(wa, { scopedTo: event.from }); * ``` * * @example Unscoped (batch / outbound) * ```ts * const tools = whatsappTools(wa); * // LLM provides `to` per-call. * ``` */ declare function whatsappTools(client: WhatsAppClient, options?: WhatsAppToolsOptions): ToolSet; interface WhatsAppToolsOptions { /** * Bind every outbound `send_*` tool to this single recipient phone. The * `to` parameter is removed from tool schemas, the LLM cannot specify a * different recipient. Use in webhook handlers to prevent agent hijacking. * * AR phone formats accepted: `+54 9 11 1234-5678`, `549112345678`, etc. */ scopedTo?: string; } type WhatsAppToolSet = ReturnType; /** * Verify the GET handshake Meta sends when you first subscribe a webhook URL. * Meta calls your endpoint with `?hub.mode=subscribe&hub.verify_token=X&hub.challenge=Y`. * You must return `hub.challenge` as plain text IF the token matches. * * @example * ```ts * // GET /api/whatsapp/webhook * const challenge = verifyWebhookSubscription( * Object.fromEntries(new URL(req.url).searchParams), * process.env.WA_WEBHOOK_VERIFY_TOKEN!, * ); * if (challenge !== null) return new Response(challenge); * return new Response("Forbidden", { status: 403 }); * ``` * * Returns the challenge string to echo back, or null if the request should * be rejected with 403. */ declare function verifyWebhookSubscription(query: Record, expectedToken: string): string | null; /** * Verify the X-Hub-Signature-256 header on POST webhooks. Meta signs the raw * request body with HMAC-SHA256 using your app secret. * * **Pass the RAW body** — not the parsed JSON. If you've already parsed, * `JSON.stringify(parsed)` will not match the signature because key ordering * and whitespace differ. Use `req.text()` (Web standard) or `req.rawBody` * (Express) before parsing. * * Throws `WhatsAppWebhookSignatureError` if invalid. Catch and return 401. * * @example * ```ts * const raw = await req.text(); * verifyWebhookSignature( * raw, * req.headers.get("x-hub-signature-256") ?? "", * process.env.META_APP_SECRET!, * ); * const event = parseWebhookEvent(JSON.parse(raw)); * ``` */ declare function verifyWebhookSignature(rawBody: string, signatureHeader: string, appSecret: string): void; /** * Parse Meta's nested webhook envelope into a normalized `WebhookEvent`. * * # Meta's envelope shape * * Meta wraps EVERYTHING in a deeply nested structure. A single inbound * message looks like: * * ``` * { object: "whatsapp_business_account", * entry: [{ id: "...", changes: [{ * field: "messages", * value: { * messaging_product: "whatsapp", * metadata: { phone_number_id: "...", display_phone_number: "..." }, * contacts: [{ profile: { name: "Lautaro" }, wa_id: "5491112345678" }], * messages: [{ from: "5491112345678", id: "wamid....", timestamp: "...", * type: "text", text: { body: "..." } }] * } * }]}] * } * ``` * * This function flattens that into one `WebhookEvent` per actual message or * status update. A single Meta payload can contain MULTIPLE events — see * `parseWebhookEvents` (plural) if you need them all. * * Returns the FIRST event in the payload (most webhooks deliver one event * per request). Use `parseWebhookEvents` for batches. */ declare function parseWebhookEvent(payload: unknown): WebhookEvent; /** * Parse Meta's webhook envelope into ALL contained events. Meta can batch * status updates — you might get 3 status events in one POST. */ declare function parseWebhookEvents(payload: unknown): WebhookEvent[]; /** * Phone-number normalization for Argentine WhatsApp recipients. * * # Why this exists * * Meta's Cloud API expects E.164 format WITHOUT the leading `+`. Argentine * users write phone numbers a dozen different ways: * * - `+54 9 11 1234-5678` * - `54 9 11 1234 5678` * - `+5491112345678` * - `011 1234-5678` (no country code) * - `11-1234-5678` * - `1112345678` (no area code prefix) * * Two AR-specific gotchas: * 1. WhatsApp requires the **`9`** after the country code for mobile numbers. * Local format `011 5555-1234` becomes `54 9 11 5555-1234`. Without the * 9, Meta rejects with "recipient not on WhatsApp" even when they are. * 2. The trunk `0` (e.g., the `0` in `011`) must be DROPPED. * * `normalizeArPhone` handles both. For non-AR numbers (anything starting * with country code !== 54), it just strips non-digits and returns. */ /** * Normalize an Argentine phone number to WhatsApp E.164 format (no `+`). * * Rules applied in order: * 1. Strip all non-digit characters. * 2. If starts with `54` (country code already present): drop trunk `0` if * next is `0`, ensure `9` present after `54` for mobile (10-digit * subscriber numbers — landlines stay without `9`). * 3. If starts with `0`: assume AR domestic, drop the `0`, prefix `549`. * 4. If starts with `15`: legacy mobile prefix, strip and prefix `549` * (best effort — defaults to area `11` if unable to detect). * 5. Otherwise: prefix `549` (assumes AR mobile, 10 digits). * * Returns the normalized string, or throws if the input doesn't look like a * valid phone number after normalization. * * @example * normalizeArPhone("+54 9 11 1234-5678") // "5491112345678" * normalizeArPhone("011 1234-5678") // "5491112345678" * normalizeArPhone("11-1234-5678") // "5491112345678" * normalizeArPhone("+1 415 555 2671") // "14155552671" (non-AR pass-through) */ declare function normalizeArPhone(input: string): string; /** * Loose validation — checks shape, not deliverability. Returns true if the * input could plausibly be a WhatsApp-deliverable phone number. Use this in * tools where the LLM might pass garbage. */ declare function isPlausibleWhatsAppPhone(input: string): boolean; /** * Error classes thrown by `@ar-agents/whatsapp`. All extend the base * `WhatsAppError` so callers can do `catch (err) if (err instanceof WhatsAppError)`. * * # Why typed errors? * * Tools surface failures back to the LLM as part of the tool result, but for * code paths outside the agent (webhook handlers, batch jobs) the typed * errors let you switch on the error class instead of regexing messages. */ declare class WhatsAppError extends ArAgentsError { constructor(message: string, cause?: unknown); } /** Construction-time configuration error (missing accessToken / phoneNumberId). */ declare class WhatsAppNotConfiguredError extends WhatsAppError { constructor(message?: string); } /** * Meta Graph API returned a 4xx with a structured error body. Includes the * Meta error code so callers can branch on common cases: * * - 131009: phone number not on WhatsApp * - 131026: outside 24-hour customer service window (need template) * - 131031: app is not associated with this phone number * - 131056: invalid recipient phone number * - 132000: template params don't match the approved template * - 190: access token expired or invalid * * Full reference: https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes */ declare class WhatsAppApiError extends WhatsAppError { /** Meta Graph API error code (numeric). e.g. 131009, 131026. */ readonly metaCode: number; readonly httpStatus: number; readonly metaSubcode?: number; readonly fbtraceId?: string; constructor(message: string, metaCode: number, httpStatus: number, metaSubcode?: number, fbtraceId?: string, cause?: unknown); } /** * The recipient phone number doesn't match a WhatsApp account. Maps to Meta * code 131009. Surface this to the user as "ese número no está en WhatsApp". */ declare class WhatsAppRecipientNotOnPlatformError extends WhatsAppApiError { constructor(metaMessage: string, fbtraceId?: string); } /** * Tried to send a free-form message outside the 24-hour customer service * window. Maps to Meta code 131026 / 131047. The fix is to send an approved * template message instead. */ declare class WhatsAppOutsideWindowError extends WhatsAppApiError { constructor(metaMessage: string, fbtraceId?: string); } /** Webhook signature verification failed — the request is not from Meta. */ declare class WhatsAppWebhookSignatureError extends WhatsAppError { constructor(message?: string); } export { type InboundMessageEvent, type PhoneNumberId, type SendResult, type StatusUpdateEvent, type UnknownEvent, type WebhookEvent, WhatsAppApiError, WhatsAppClient, type WhatsAppClientOptions, WhatsAppError, type WhatsAppMessageId, WhatsAppNotConfiguredError, WhatsAppOutsideWindowError, type WhatsAppRecipient, WhatsAppRecipientNotOnPlatformError, type WhatsAppToolSet, WhatsAppWebhookSignatureError, isPlausibleWhatsAppPhone, normalizeArPhone, parseWebhookEvent, parseWebhookEvents, verifyWebhookSignature, verifyWebhookSubscription, whatsappTools };