import type { JsonObject } from "type-fest" import * as z from "zod" import { defineAction } from "../../automation/actions" import { getWhatsAppApi } from "@automate.ax/integration-contracts/whatsapp" const WHATSAPP_MESSAGING_SCOPE = "whatsapp_business_messaging" const WHATSAPP_MANAGEMENT_SCOPE = "whatsapp_business_management" const POSITIONAL_VARIABLE_PATTERN = /{{(\d+)}}/g const MESSAGE_TEMPLATE_CATEGORY_SCHEMA = z.enum(["marketing", "utility"]) const PROVIDER_MESSAGE_TEMPLATE_CATEGORY = { marketing: "MARKETING", utility: "UTILITY", } as const const PUBLIC_MESSAGE_TEMPLATE_CATEGORY = { MARKETING: "marketing", UTILITY: "utility", } as const const PROVIDER_MESSAGE_TEMPLATE_STATUS = { APPROVED: "approved", ARCHIVED: "archived", DELETED: "deleted", DISABLED: "disabled", IN_APPEAL: "inAppeal", LIMIT_EXCEEDED: "limitExceeded", PAUSED: "paused", PENDING: "pending", PENDING_DELETION: "pendingDeletion", REJECTED: "rejected", } as const const CREATE_MESSAGE_TEMPLATE_RESPONSE_SCHEMA = z.looseObject({ category: z .enum(["MARKETING", "UTILITY"]) .transform((category) => PUBLIC_MESSAGE_TEMPLATE_CATEGORY[category]), id: z.string(), status: z .enum([ "APPROVED", "ARCHIVED", "DELETED", "DISABLED", "IN_APPEAL", "LIMIT_EXCEEDED", "PAUSED", "PENDING", "PENDING_DELETION", "REJECTED", ]) .transform((status) => PROVIDER_MESSAGE_TEMPLATE_STATUS[status]), }) const CREATED_MESSAGE_TEMPLATE_SCHEMA = z.object({ /** Category accepted by Meta, including any automatic recategorization. */ category: MESSAGE_TEMPLATE_CATEGORY_SCHEMA, /** Meta message-template ID. */ templateId: z.string(), /** Initial provider review state. */ status: z.enum([ "approved", "archived", "deleted", "disabled", "inAppeal", "limitExceeded", "paused", "pending", "pendingDeletion", "rejected", ]), }) const MESSAGE_TEMPLATE_HEADER_SCHEMA = z.discriminatedUnion("format", [ z.object({ /** Header format. */ format: z.literal("text"), /** Example values for positional variables such as `{{1}}`. */ variableExamples: z.string().min(1).array().optional(), /** Header text, including optional positional variables. */ text: z.string().min(1).max(60), }), z.object({ /** Header format. */ format: z.enum(["document", "image", "video"]), /** Uploaded Meta media handle used as the review example. */ exampleHandle: z.string().min(1), }), z.object({ /** Header format. */ format: z.literal("location"), }), ]) const MESSAGE_TEMPLATE_BUTTON_SCHEMA = z.discriminatedUnion("type", [ z.object({ /** Button label. */ text: z.string().min(1).max(25), type: z.literal("quickReply"), }), z.object({ /** E.164 phone number called by the button. */ phoneNumber: z.string().min(1), /** Button label. */ text: z.string().min(1).max(25), type: z.literal("phoneNumber"), }), z.object({ /** Complete example URL when `url` contains a positional variable. */ variableExample: z.string().min(1).optional(), /** Button label. */ text: z.string().min(1).max(25), type: z.literal("url"), /** Static URL or URL ending in one positional variable. */ url: z.string().min(1).max(2000), }), ]) const CREATE_MESSAGE_TEMPLATE_INPUT_SCHEMA = z .object({ /** Lets Meta recategorize the template instead of rejecting its category. */ allowCategoryChange: z.boolean().default(true), /** Template body text, including optional positional variables. */ body: z.string().min(1).max(1024), /** Positional example values for body variables such as `{{1}}`. */ bodyVariableExamples: z.string().min(1).array().optional(), /** Optional call-to-action and quick-reply buttons. */ buttons: MESSAGE_TEMPLATE_BUTTON_SCHEMA.array().min(1).max(10).optional(), /** Meta template category. */ category: MESSAGE_TEMPLATE_CATEGORY_SCHEMA, /** Optional footer text. */ footer: z.string().min(1).max(60).optional(), /** Optional text, media, or location header. */ header: MESSAGE_TEMPLATE_HEADER_SCHEMA.optional(), /** Template language or locale code, such as `en_US`. */ languageCode: z.string().min(2), /** * Lowercase template name containing only letters, numbers, and * underscores. */ templateName: z .string() .min(1) .max(512) .regex(/^[a-z0-9_]+$/), }) .superRefine((input, context) => { const variableCount = new Set( [...input.body.matchAll(POSITIONAL_VARIABLE_PATTERN)].map( ([, position]) => position, ), ).size if ((input.bodyVariableExamples?.length ?? 0) !== variableCount) { context.addIssue({ code: "custom", message: `Provide exactly ${variableCount} example value${variableCount === 1 ? "" : "s"}, one for each positional variable in body.`, path: ["bodyVariableExamples"], }) } }) const MESSAGE_RESPONSE_SCHEMA = z.looseObject({ contacts: z .looseObject({ input: z.string(), wa_id: z.string() }) .array() .optional(), messages: z .looseObject({ id: z.string(), message_status: z .enum(["accepted", "held_for_quality_assessment", "paused"]) .optional(), }) .array(), messaging_product: z.literal("whatsapp"), }) const SENT_MESSAGE_SCHEMA = z.object({ /** WhatsApp message ID used to correlate delivery status events. */ messageId: z.string(), /** Immediate provider acceptance state, when returned. */ status: z .enum(["accepted", "held_for_quality_assessment", "paused"]) .optional(), /** Normalized WhatsApp recipient ID. */ recipientId: z.string(), }) const TEMPLATE_PARAMETER_SCHEMA = z.discriminatedUnion("type", [ z.object({ text: z.string(), type: z.literal("text") }), z.object({ currency: z.object({ amount1000: z.number().int(), code: z.string().length(3), fallbackValue: z.string(), }), type: z.literal("currency"), }), z.object({ dateTime: z.object({ fallbackValue: z.string() }), type: z.literal("dateTime"), }), z.object({ image: z.object({ link: z.url() }), type: z.literal("image") }), z.object({ video: z.object({ link: z.url() }), type: z.literal("video") }), z.object({ document: z.object({ filename: z.string().optional(), link: z.url() }), type: z.literal("document"), }), ]) const TEMPLATE_COMPONENT_SCHEMA = z.discriminatedUnion("type", [ z.object({ parameters: TEMPLATE_PARAMETER_SCHEMA.array(), type: z.literal("header"), }), z.object({ parameters: TEMPLATE_PARAMETER_SCHEMA.array(), type: z.literal("body"), }), z.object({ index: z.number().int().min(0).max(9), parameters: TEMPLATE_PARAMETER_SCHEMA.array(), subType: z.enum(["quickReply", "url"]), type: z.literal("button"), }), ]) /** Creates a marketing or utility template for the connected business account. */ export const createWhatsAppMessageTemplate = defineAction( "Create WhatsApp message template", ) .describe( "Creates a WhatsApp message template and submits it for Meta review.", ) .account("whatsapp", WHATSAPP_MANAGEMENT_SCOPE) .input(CREATE_MESSAGE_TEMPLATE_INPUT_SCHEMA) .output(CREATED_MESSAGE_TEMPLATE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const api = getWhatsAppApi(account.secret) const result = await api.request(`/${api.wabaId}/message_templates`, { body: { allow_category_change: input.allowCategoryChange, category: PROVIDER_MESSAGE_TEMPLATE_CATEGORY[input.category], components: toMessageTemplateComponents(input), language: input.languageCode, name: input.templateName, }, responseSchema: CREATE_MESSAGE_TEMPLATE_RESPONSE_SCHEMA, }) return { category: result.category, status: result.status, templateId: result.id, } }) /** Sends freeform text inside an open 24-hour customer-service window. */ export const sendWhatsAppMessage = defineAction("Send WhatsApp message") .describe("Sends freeform text to an opted-in WhatsApp recipient.") .account("whatsapp", WHATSAPP_MESSAGING_SCOPE) .input( z.object({ /** Text body, limited to WhatsApp's 4,096-character maximum. */ body: z.string().min(1).max(4096), /** Opaque callback value returned in status webhooks. */ callbackData: z.string().max(512).optional(), /** Whether WhatsApp should render link previews. */ previewUrl: z.boolean().optional(), /** Message ID to quote as a reply. */ replyToMessageId: z.string().min(1).optional(), /** Recipient phone number or WhatsApp ID. */ to: z.string().min(1), }), ) .output(SENT_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => sendWhatsAppPayload(account.secret, input.to, { ...(input.callbackData && { biz_opaque_callback_data: input.callbackData, }), ...(input.replyToMessageId && { context: { message_id: input.replyToMessageId }, }), text: { body: input.body, preview_url: input.previewUrl ?? false }, type: "text", }), ) /** Sends an approved WhatsApp template, including outside the service window. */ export const sendWhatsAppTemplateMessage = defineAction( "Send WhatsApp template message", ) .describe( "Sends an approved WhatsApp message template to an opted-in recipient.", ) .account("whatsapp", WHATSAPP_MESSAGING_SCOPE) .input( z.object({ /** Opaque callback value returned in status webhooks. */ callbackData: z.string().max(512).optional(), /** Template component values in provider order. */ components: TEMPLATE_COMPONENT_SCHEMA.array().optional(), /** Template language or locale code, such as `en_US`. */ languageCode: z.string().min(1), /** Approved template name. */ templateName: z.string().min(1), /** Recipient phone number or WhatsApp ID. */ to: z.string().min(1), }), ) .output(SENT_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => sendWhatsAppPayload(account.secret, input.to, { ...(input.callbackData && { biz_opaque_callback_data: input.callbackData, }), template: { ...(input.components && { components: input.components.map(toTemplateComponent), }), language: { code: input.languageCode }, name: input.templateName, }, type: "template", }), ) /** * Sends one provider-native individual message payload. * * @param secret - Resolved WhatsApp account secret. * @param to - Recipient phone number or WhatsApp ID. * @param payload - Message-type-specific Graph API body. */ async function sendWhatsAppPayload( secret: Record, to: string, payload: JsonObject, ) { const api = getWhatsAppApi(secret) const result = await api.request(`/${api.phoneNumberId}/messages`, { body: { messaging_product: "whatsapp", recipient_type: "individual", to, ...payload, }, responseSchema: MESSAGE_RESPONSE_SCHEMA, }) const message = result.messages[0] if (!message) throw new Error("WhatsApp did not return a message ID.") return { messageId: message.id, recipientId: result.contacts?.[0]?.wa_id ?? to, status: message.message_status, } } /** * Converts one public template component to Meta's snake-cased shape. * * @param component - Typed public template component. */ function toTemplateComponent( component: z.output, ): JsonObject { return { ...(component.type === "button" && { index: String(component.index), sub_type: component.subType === "quickReply" ? "quick_reply" : "url", }), parameters: component.parameters.map(toTemplateParameter), type: component.type, } } /** * Converts one public template parameter to Meta's provider-native shape. * * @param parameter - Typed public template parameter. */ function toTemplateParameter( parameter: z.output, ): JsonObject { switch (parameter.type) { case "text": return parameter case "currency": return { currency: { amount_1000: parameter.currency.amount1000, code: parameter.currency.code, fallback_value: parameter.currency.fallbackValue, }, type: parameter.type, } case "dateTime": return { date_time: { fallback_value: parameter.dateTime.fallbackValue }, type: "date_time", } case "image": return parameter case "video": return parameter case "document": return parameter } } /** * Builds Meta's provider-native message-template component array. * * @param input - Validated public template input. */ function toMessageTemplateComponents( input: z.output, ): JsonObject[] { return [ ...(input.header ? [toMessageTemplateHeader(input.header)] : []), { ...(input.bodyVariableExamples && { example: { body_text: [input.bodyVariableExamples] }, }), text: input.body, type: "BODY", }, ...(input.footer ? [{ text: input.footer, type: "FOOTER" }] : []), ...(input.buttons ? [ { buttons: input.buttons.map(toMessageTemplateButton), type: "BUTTONS", }, ] : []), ] } /** * Converts one public message-template header to Meta's shape. * * @param header - Typed public header definition. */ function toMessageTemplateHeader( header: z.output, ): JsonObject { switch (header.format) { case "text": return { ...(header.variableExamples && { example: { header_text: header.variableExamples }, }), format: "TEXT", text: header.text, type: "HEADER", } case "document": case "image": case "video": return { example: { header_handle: [header.exampleHandle] }, format: header.format.toUpperCase(), type: "HEADER", } case "location": return { format: "LOCATION", type: "HEADER" } } } /** * Converts one public message-template button to Meta's shape. * * @param button - Typed public button definition. */ function toMessageTemplateButton( button: z.output, ): JsonObject { switch (button.type) { case "quickReply": return { text: button.text, type: "QUICK_REPLY" } case "phoneNumber": return { phone_number: button.phoneNumber, text: button.text, type: "PHONE_NUMBER", } case "url": return { ...(button.variableExample && { example: [button.variableExample] }), text: button.text, type: "URL", url: button.url, } } }