import type { MessageTemplate, MessageTemplatePreview, MessageTemplateVariable } from '../types/whatsapp.ts' type TemplateHeaderFormat = NonNullable type TemplateHeaderComponent = { type: 'HEADER' format: TemplateHeaderFormat text?: string example?: { header_text?: Array header_handle?: Array } } type TemplateBodyComponent = { type: 'BODY' text: string example?: { body_text?: [Array] } } type TemplateFooterComponent = { type: 'FOOTER' text: string } type TemplateButton = { type: string text?: string url?: string phone_number?: string example?: Array } type TemplateButtonsComponent = { type: 'BUTTONS' buttons: Array } type TemplateComponent = | TemplateHeaderComponent | TemplateBodyComponent | TemplateFooterComponent | TemplateButtonsComponent | { type: string [key: string]: unknown } const placeholderPattern = /{{(\d+)}}/g export type CreateMessageTemplateTextHeaderInput = { type: 'HEADER' format: 'TEXT' text: string exampleText?: Array } export type CreateMessageTemplateLocationHeaderInput = { type: 'HEADER' format: 'LOCATION' } export type CreateMessageTemplateMediaHeaderInput = { type: 'HEADER' format: 'IMAGE' | 'VIDEO' | 'DOCUMENT' exampleHandle?: string exampleUrl?: string } export type CreateMessageTemplateBodyInput = { type: 'BODY' text: string examples?: Array } export type CreateMessageTemplateFooterInput = { type: 'FOOTER' text: string } export type CreateMessageTemplateQuickReplyButtonInput = { type: 'QUICK_REPLY' text: string } export type CreateMessageTemplatePhoneNumberButtonInput = { type: 'PHONE_NUMBER' text: string phoneNumber: string } export type CreateMessageTemplateUrlButtonInput = { type: 'URL' text: string url: string example?: Array } export type CreateMessageTemplateButtonInput = | CreateMessageTemplateQuickReplyButtonInput | CreateMessageTemplatePhoneNumberButtonInput | CreateMessageTemplateUrlButtonInput export type CreateMessageTemplateButtonsInput = { type: 'BUTTONS' buttons: Array } export type CreateMessageTemplateComponentInput = | CreateMessageTemplateTextHeaderInput | CreateMessageTemplateLocationHeaderInput | CreateMessageTemplateMediaHeaderInput | CreateMessageTemplateBodyInput | CreateMessageTemplateFooterInput | CreateMessageTemplateButtonsInput type MessageTemplatePayloadButton = | { type: 'QUICK_REPLY' text: string } | { type: 'PHONE_NUMBER' text: string phone_number: string } | { type: 'URL' text: string url: string example?: Array } type MessageTemplatePayloadComponent = | { type: 'HEADER' format: 'TEXT' text: string example?: { header_text: Array } } | { type: 'HEADER' format: 'LOCATION' } | { type: 'HEADER' format: 'IMAGE' | 'VIDEO' | 'DOCUMENT' example: { header_handle: Array } } | { type: 'BODY' text: string example?: { body_text: [Array] } } | { type: 'FOOTER' text: string } | { type: 'BUTTONS' buttons: Array } export type UploadedMessageTemplateHeaderMedia = { componentIndex: number format: 'IMAGE' | 'VIDEO' | 'DOCUMENT' sourceUrl: string fileHandle: string } export async function createMessageTemplatePayload( input: Pick & { components: Array }, options?: { uploadHeaderMedia?: (url: string) => Promise<{ fileHandle: string }> }, ): Promise<{ template: Pick uploadedHeaderMedia: Array }> { validateComponentSet(input.components) const uploadedHeaderMedia: Array = [] const components = sortTemplateComponents( await Promise.all( input.components.map((component, index) => normalizeTemplateComponent(component, index, uploadedHeaderMedia, options?.uploadHeaderMedia), ), ), ) return { template: { name: input.name, category: input.category, language: input.language, components: components as MessageTemplate['components'], }, uploadedHeaderMedia, } } export function getMessageTemplateVariables(components: Array): Array { const variables: Array = [] const header = components.find(isMediaHeaderComponent) if (header) { variables.push({ type: 'MEDIA', example: header.example?.header_handle?.[0] }) } const body = components.find(isBodyComponent) if (body?.example?.body_text?.[0]) { variables.push( ...body.example.body_text[0].map((example) => ({ type: 'BODY' as const, example, })), ) } else if (body) { variables.push(...getPlaceholderMatches(body.text).map(() => ({ type: 'BODY' as const }))) } const buttons = components.find(isButtonsComponent) const urlButtons = buttons?.buttons.filter((button) => button.type === 'URL') ?? [] for (const button of urlButtons) { if (Array.isArray(button.example) && button.example.length > 0) { variables.push( ...button.example.map((example) => ({ type: 'BUTTON' as const, example, })), ) continue } if (typeof button.url === 'string') { variables.push(...getPlaceholderMatches(button.url).map(() => ({ type: 'BUTTON' as const }))) } } return variables } export function getMessageTemplatePreview(components: Array): MessageTemplatePreview { const header = components.find(isHeaderComponent) const body = components.find(isBodyComponent) const footer = components.find(isFooterComponent) const buttons = components.find(isButtonsComponent) return { headerFormat: header?.format, headerText: getHeaderText(header), bodyText: body?.text, footerText: footer?.text, buttons: buttons?.buttons.map(formatButtonPreview) ?? [], } } function getPlaceholderMatches(text: string): Array { return text.match(placeholderPattern) ?? [] } function isHeaderComponent(component: TemplateComponent): component is TemplateHeaderComponent { return component.type === 'HEADER' } function isMediaHeaderComponent(component: TemplateComponent): component is TemplateHeaderComponent { return ( component.type === 'HEADER' && (component.format === 'IMAGE' || component.format === 'VIDEO' || component.format === 'DOCUMENT') ) } function isBodyComponent(component: TemplateComponent): component is TemplateBodyComponent { return component.type === 'BODY' } function isFooterComponent(component: TemplateComponent): component is TemplateFooterComponent { return component.type === 'FOOTER' } function isButtonsComponent(component: TemplateComponent): component is TemplateButtonsComponent { return component.type === 'BUTTONS' } function getHeaderText(header?: TemplateHeaderComponent): string | undefined { if (!header) { return undefined } if (header.format === 'TEXT') { return header.text ?? header.example?.header_text?.[0] } if (header.format === 'LOCATION') { return 'location header' } return `${header.format.toLowerCase()} header` } function formatButtonPreview(button: TemplateButton): string { const label = typeof button.text === 'string' && button.text.trim().length > 0 ? button.text : button.type if (button.type === 'URL' && typeof button.url === 'string') { return `URL: ${label} -> ${button.url}` } if (button.type === 'PHONE_NUMBER' && typeof button.phone_number === 'string') { return `PHONE_NUMBER: ${label} -> ${button.phone_number}` } return `${button.type}: ${label}` } function validateComponentSet(components: Array) { const counts = components.reduce>( (accumulator, component) => { accumulator[component.type] = (accumulator[component.type] ?? 0) + 1 return accumulator }, { HEADER: 0, BODY: 0, FOOTER: 0, BUTTONS: 0, }, ) if (counts.BODY !== 1) { throw new Error('A WhatsApp message template must include exactly one BODY component.') } if (counts.HEADER > 1) { throw new Error('A WhatsApp message template can include at most one HEADER component.') } if (counts.FOOTER > 1) { throw new Error('A WhatsApp message template can include at most one FOOTER component.') } if (counts.BUTTONS > 1) { throw new Error('A WhatsApp message template can include at most one BUTTONS component.') } } async function normalizeTemplateComponent( component: CreateMessageTemplateComponentInput, componentIndex: number, uploadedHeaderMedia: Array, uploadHeaderMedia?: (url: string) => Promise<{ fileHandle: string }>, ): Promise { if (component.type === 'HEADER') { if (component.format === 'TEXT') { const placeholders = getPlaceholderIndexes(component.text, 'header text') assertExamplesMatchPlaceholders(placeholders, component.exampleText, 'header text') return { type: 'HEADER', format: 'TEXT', text: component.text, ...(placeholders.length > 0 ? { example: { header_text: component.exampleText ?? [], }, } : {}), } } if (component.format === 'LOCATION') { return { type: 'HEADER', format: 'LOCATION', } } const sourceCount = Number(Boolean(component.exampleHandle)) + Number(Boolean(component.exampleUrl)) if (sourceCount !== 1) { throw new Error('Provide exactly one of exampleHandle or exampleUrl for media headers.') } if (component.exampleUrl) { if (!uploadHeaderMedia) { throw new Error('An uploadHeaderMedia callback is required to use exampleUrl in media headers.') } const attachment = await uploadHeaderMedia(component.exampleUrl) uploadedHeaderMedia.push({ componentIndex, format: component.format, sourceUrl: component.exampleUrl, fileHandle: attachment.fileHandle, }) return { type: 'HEADER', format: component.format, example: { header_handle: [attachment.fileHandle], }, } } return { type: 'HEADER', format: component.format, example: { header_handle: [component.exampleHandle as string], }, } } if (component.type === 'BODY') { const placeholders = getPlaceholderIndexes(component.text, 'body text') assertExamplesMatchPlaceholders(placeholders, component.examples, 'body text') return { type: 'BODY', text: component.text, ...(placeholders.length > 0 ? { example: { body_text: [component.examples ?? []], }, } : {}), } } if (component.type === 'FOOTER') { return { type: 'FOOTER', text: component.text, } } return { type: 'BUTTONS', buttons: component.buttons.map(normalizeButton), } } function normalizeButton(button: CreateMessageTemplateButtonInput): MessageTemplatePayloadButton { if (button.type === 'QUICK_REPLY') { return { type: 'QUICK_REPLY', text: button.text, } } if (button.type === 'PHONE_NUMBER') { return { type: 'PHONE_NUMBER', text: button.text, phone_number: button.phoneNumber, } } const placeholders = getPlaceholderIndexes(button.url, `URL button "${button.text}"`) assertExamplesMatchPlaceholders(placeholders, button.example, `URL button "${button.text}"`) return { type: 'URL', text: button.text, url: button.url, ...(placeholders.length > 0 ? { example: button.example ?? [] } : {}), } } function getPlaceholderIndexes(text: string, fieldName: string): Array { const indexes = [...text.matchAll(placeholderPattern)].map((match) => Number.parseInt(match[1], 10)) if (indexes.length === 0) { return [] } const uniqueIndexes = [...new Set(indexes)].sort((left, right) => left - right) for (let index = 0; index < uniqueIndexes.length; index += 1) { const expected = index + 1 if (uniqueIndexes[index] !== expected) { throw new Error(`Invalid placeholders in ${fieldName}. Variables must be sequential and start at {{1}}.`) } } return uniqueIndexes } function assertExamplesMatchPlaceholders( placeholders: Array, examples: Array | undefined, fieldName: string, ) { if (placeholders.length === 0) { if (examples && examples.length > 0) { throw new Error(`Examples were provided for ${fieldName}, but the text has no variables.`) } return } if (!examples || examples.length !== placeholders.length) { throw new Error( `Expected ${placeholders.length} example value(s) for ${fieldName} to match its template variables.`, ) } } function sortTemplateComponents( components: Array, ): Array { const order: Record = { HEADER: 0, BODY: 1, FOOTER: 2, BUTTONS: 3, } return [...components].sort((left, right) => order[left.type] - order[right.type]) }