From a87213f7dc3a0e83b50ed42a5042f74c180b8ab9 Mon Sep 17 00:00:00 2001 From: Devi R Date: Mon, 29 Jun 2026 10:21:44 +0530 Subject: [PATCH 1/5] feat(template): add shared WhatsApp/Twilio message-template utilities Move the message-template logic (filtering, normalizing, validation, preview rendering and send-payload building) into the shared package so both web and mobile can consume a single implementation. Adds extractFilenameFromUrl to url.ts. --- .gitignore | 2 +- src/index.ts | 69 ++++++- src/template.ts | 437 ++++++++++++++++++++++++++++++++++++++++++ src/types/template.ts | 137 +++++++++++++ src/url.ts | 20 ++ test/template.test.ts | 305 +++++++++++++++++++++++++++++ 6 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 src/template.ts create mode 100644 src/types/template.ts create mode 100644 test/template.test.ts diff --git a/.gitignore b/.gitignore index f0e03a7..cb0d28c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules dist docs -coverage \ No newline at end of file +coveragepackage-lock.json diff --git a/src/index.ts b/src/index.ts index ca14ac0..c0e4c0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,12 @@ import { formatNumber, } from './helpers'; -import { toURL, isSameHost, isValidDomain } from './url'; +import { + toURL, + isSameHost, + isValidDomain, + extractFilenameFromUrl, +} from './url'; import { getRecipients } from './email'; @@ -42,6 +47,28 @@ import { getMaxUploadSizeByChannel, } from './fileUploadRules'; +import { + extractVariables, + renderTemplatePreview, + renderTemplateLabel, + buildPreviewSegments, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + getHeaderSubtitle, + normalizeWhatsApp, + normalizeTwilio, + getTemplates, + filterTemplatesByQuery, + createEmptyFormState, + isTemplateComplete, + buildTemplateParams, + renderTemplateMessage, + buildTemplateSendPayload, + MEDIA_FORMATS, +} from './template'; + export { clamp, coerceToDate, @@ -68,6 +95,7 @@ export { toURL, isSameHost, isValidDomain, + extractFilenameFromUrl, trimContent, downloadFile, getFileInfo, @@ -75,4 +103,43 @@ export { formatNumber, getAllowedFileTypesByChannel, getMaxUploadSizeByChannel, + extractVariables, + renderTemplatePreview, + renderTemplateLabel, + buildPreviewSegments, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + getHeaderSubtitle, + normalizeWhatsApp, + normalizeTwilio, + getTemplates, + filterTemplatesByQuery, + createEmptyFormState, + isTemplateComplete, + buildTemplateParams, + renderTemplateMessage, + buildTemplateSendPayload, + MEDIA_FORMATS, }; + +export type { + WhatsAppTemplateHeaderFormat, + WhatsAppTemplateButton, + WhatsAppTemplateComponent, + WhatsAppMessageTemplate, + TwilioContentTemplate, + TwilioContentTemplates, + TemplatePlatform, + NormalizedTemplateHeader, + NormalizedTemplateButton, + NormalizedTemplate, + TemplateButtonParam, + WhatsAppProcessedParams, + TwilioProcessedParams, + TemplateSendParams, + TemplateFormState, +} from './types/template'; + +export type { PreviewSegment } from './template'; diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..caed2e1 --- /dev/null +++ b/src/template.ts @@ -0,0 +1,437 @@ +import { extractFilenameFromUrl } from './url'; +import { + NormalizedTemplate, + NormalizedTemplateButton, + NormalizedTemplateHeader, + TemplateButtonParam, + TemplateFormState, + TemplateSendParams, + TwilioContentTemplate, + TwilioProcessedParams, + WhatsAppMessageTemplate, + WhatsAppProcessedParams, + WhatsAppTemplateComponent, +} from './types/template'; + +const MEDIA_FORMATS = new Set(['IMAGE', 'VIDEO', 'DOCUMENT']); +// Component types that aren't supported when sending a template. +const UNSUPPORTED_COMPONENT_TYPES = new Set([ + 'LIST', + 'PRODUCT', + 'CATALOG', + 'CALL_PERMISSION_REQUEST', +]); +const TWILIO_MEDIA_TEMPLATE_TYPE = 'media'; +const VARIABLE_REGEX = /\{\{([^}]+)\}\}/g; + +const findComponent = ( + template: WhatsAppMessageTemplate, + type: T +): Extract | undefined => { + return template.components?.find(component => component.type === type) as + | Extract + | undefined; +}; + +const isCsatTemplate = (name: string) => + name.startsWith('customer_satisfaction_survey'); + +export const extractVariables = (body: string): string[] => { + if (!body) return []; + const regex = new RegExp(VARIABLE_REGEX.source, 'g'); + const seen = new Set(); + const ordered: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(body)) !== null) { + const key = match[1].trim(); + if (!seen.has(key)) { + seen.add(key); + ordered.push(key); + } + } + return ordered; +}; + +export const renderTemplatePreview = ( + body: string, + values: Record +): string => { + if (!body) return ''; + return body.replace(VARIABLE_REGEX, (_match, rawKey) => { + const key = rawKey.trim(); + const value = values[key]; + return value && value.length > 0 ? value : `{{${key}}}`; + }); +}; + +export const renderTemplateLabel = (body: string): string => { + if (!body) return ''; + return body.replace(VARIABLE_REGEX, (_match, rawKey) => `{ ${rawKey.trim()} }`); +}; + +export type PreviewSegment = { text: string; filled: boolean }; + +export const buildPreviewSegments = ( + body: string, + values: Record +): PreviewSegment[] => { + if (!body) return []; + const segments: PreviewSegment[] = []; + const regex = new RegExp(VARIABLE_REGEX.source, 'g'); + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(body)) !== null) { + if (match.index > lastIndex) { + segments.push({ text: body.slice(lastIndex, match.index), filled: false }); + } + const key = match[1].trim(); + const value = values[key]; + if (value && value.length > 0) { + segments.push({ text: value, filled: true }); + } else { + segments.push({ text: `{{${key}}}`, filled: false }); + } + lastIndex = regex.lastIndex; + } + if (lastIndex < body.length) { + segments.push({ text: body.slice(lastIndex), filled: false }); + } + return segments; +}; + +/** + * Filters WhatsApp templates down to the ones that can be sent: + * - requires status + components + * - status (case-insensitive) === 'approved' + * - category !== 'AUTHENTICATION' + * - name does not start with 'customer_satisfaction_survey' + * - no LIST/PRODUCT/CATALOG/CALL_PERMISSION_REQUEST component and no LOCATION header + */ +export const isSendableTemplate = ( + template: WhatsAppMessageTemplate +): boolean => { + if (!template || !template.status || !template.components) return false; + if (template.status.toLowerCase() !== 'approved') return false; + if (template.category === 'AUTHENTICATION') return false; + if (template.name && isCsatTemplate(template.name)) return false; + + const hasUnsupportedComponents = template.components.some( + component => + UNSUPPORTED_COMPONENT_TYPES.has(component.type) || + (component.type === 'HEADER' && component.format === 'LOCATION') + ); + if (hasUnsupportedComponents) return false; + + return true; +}; + +export const hasMediaHeader = (template: NormalizedTemplate): boolean => { + return template.header ? MEDIA_FORMATS.has(template.header.format) : false; +}; + +export const isDocumentHeader = (template: NormalizedTemplate): boolean => { + return template.header?.format === 'DOCUMENT'; +}; + +export const getMediaType = (template: NormalizedTemplate): string => { + return template.header ? template.header.format.toLowerCase() : ''; +}; + +const headerLabelMap: Record = { + IMAGE: 'Image Header', + VIDEO: 'Video Header', + DOCUMENT: 'Document Header', + LOCATION: 'Location Header', +}; + +export const getHeaderSubtitle = ( + template: NormalizedTemplate +): string | undefined => { + const header = template.header; + if (!header) return undefined; + if (header.format === 'TEXT') return header.text; + return headerLabelMap[header.format]; +}; + +const extractHeader = ( + template: WhatsAppMessageTemplate +): NormalizedTemplateHeader | undefined => { + const header = findComponent(template, 'HEADER'); + if (!header) return undefined; + const format = header.format; + if (!format) return undefined; + return { format, text: header.text }; +}; + +// Labels for the list-row action chips (display only). +const extractActions = ( + template: WhatsAppMessageTemplate +): string[] | undefined => { + const buttons = findComponent(template, 'BUTTONS'); + if (!buttons?.buttons?.length) return undefined; + const labels = buttons.buttons + .map(button => button.text?.trim()) + .filter((text): text is string => Boolean(text)); + return labels.length > 0 ? labels : undefined; +}; + +// Collect only the buttons that require a user-supplied parameter (URL buttons +// with a `{{ }}` variable, and COPY_CODE buttons), preserving their positional index. +const extractButtonParams = ( + template: WhatsAppMessageTemplate +): NormalizedTemplateButton[] | undefined => { + const buttonComponents = (template.components || []).filter( + component => component.type === 'BUTTONS' + ); + const result: NormalizedTemplateButton[] = []; + buttonComponents.forEach(component => { + if (component.type !== 'BUTTONS' || !component.buttons) return; + component.buttons.forEach((button, index) => { + if (button.type === 'URL' && button.url && button.url.includes('{{')) { + const buttonVars = button.url.match(VARIABLE_REGEX) || []; + if (buttonVars.length > 0) { + result.push({ + index, + type: 'url', + url: button.url, + variables: buttonVars.map(v => v.replace(/{{|}}/g, '')), + }); + } + } + if (button.type === 'COPY_CODE') { + result.push({ index, type: 'copy_code' }); + } + }); + }); + return result.length > 0 ? result : undefined; +}; + +export const normalizeWhatsApp = ( + template: WhatsAppMessageTemplate +): NormalizedTemplate => { + const body = findComponent(template, 'BODY'); + const bodyText = body?.text ?? ''; + return { + id: template.name, + name: template.name, + platform: 'whatsapp', + language: template.language, + category: template.category, + namespace: template.namespace, + body: bodyText, + variables: extractVariables(bodyText), + parameterFormat: template.parameterFormat, + header: extractHeader(template), + actions: extractActions(template), + buttons: extractButtonParams(template), + }; +}; + +const getTwilioMediaUrl = (template: TwilioContentTemplate): string => { + return template.types?.['twilio/media']?.media?.[0] ?? ''; +}; + +export const normalizeTwilio = ( + template: TwilioContentTemplate +): NormalizedTemplate | null => { + // Twilio templates filter with an exact (case-sensitive) `status === 'approved'`. + if (template.status !== 'approved') return null; + const body = template.body || ''; + const isMediaTemplate = template.templateType === TWILIO_MEDIA_TEMPLATE_TYPE; + const mediaUrl = isMediaTemplate ? getTwilioMediaUrl(template) : ''; + const mediaVariableKey = mediaUrl + ? mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null + : null; + return { + id: template.contentSid, + name: template.friendlyName, + platform: 'twilio', + language: template.language, + category: template.category, + body, + variables: extractVariables(body), + isMediaTemplate, + mediaVariableKey, + templateMediaUrl: mediaUrl, + }; +}; + +/** + * Normalizes the raw WhatsApp + Twilio template arrays carried by an inbox into + * a single list of sendable `NormalizedTemplate`s. + */ +export const getTemplates = ( + messageTemplates: WhatsAppMessageTemplate[] | undefined, + contentTemplates: TwilioContentTemplate[] | undefined +): NormalizedTemplate[] => { + const whatsapp = (messageTemplates || []) + .filter(isSendableTemplate) + .map(normalizeWhatsApp); + const twilio = (contentTemplates || []) + .map(normalizeTwilio) + .filter((entry): entry is NormalizedTemplate => entry !== null); + return [...whatsapp, ...twilio]; +}; + +// Search by template name only (WhatsApp `name`, Twilio `friendly_name`). +export const filterTemplatesByQuery = ( + templates: NormalizedTemplate[], + query: string +): NormalizedTemplate[] => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) return templates; + return templates.filter(template => + template.name.toLowerCase().includes(trimmed) + ); +}; + +export const createEmptyFormState = (): TemplateFormState => ({ + bodyValues: {}, + mediaUrl: '', + mediaName: '', + buttonValues: {}, +}); + +// Empty/missing values are invalid via a plain truthiness check, without trimming. +const isFilled = (value: string | undefined): boolean => Boolean(value); + +// Whether the Twilio media variable input is in play. +const hasTwilioMediaVariable = (template: NormalizedTemplate): boolean => { + return Boolean(template.isMediaTemplate && template.mediaVariableKey); +}; + +/** + * Whether every required input for the template has been filled, for both platforms. + */ +export const isTemplateComplete = ( + template: NormalizedTemplate, + state: TemplateFormState +): boolean => { + if (template.platform === 'twilio') { + const mediaVariable = hasTwilioMediaVariable(template); + if (template.variables.length === 0 && !mediaVariable) return true; + if (template.variables.some(key => !isFilled(state.bodyValues[key]))) + return false; + if (mediaVariable && !isFilled(state.mediaUrl)) return false; + return true; + } + + const media = hasMediaHeader(template); + // Early-returns valid when there are no variables and no media header, + // even if the template has buttons. + if (template.variables.length === 0 && !media) return true; + if (media && !isFilled(state.mediaUrl)) return false; + if (template.variables.some(key => !isFilled(state.bodyValues[key]))) + return false; + if ( + template.buttons?.some(button => !isFilled(state.buttonValues[button.index])) + ) + return false; + return true; +}; + +const buildWhatsAppParams = ( + template: NormalizedTemplate, + state: TemplateFormState +): TemplateSendParams => { + const processedParams: WhatsAppProcessedParams = {}; + + if (template.variables.length > 0) { + const body: Record = {}; + template.variables.forEach(key => { + body[key] = state.bodyValues[key] ?? ''; + }); + processedParams.body = body; + } + + if (hasMediaHeader(template)) { + processedParams.header = { + media_url: state.mediaUrl, + media_type: getMediaType(template), + }; + if (isDocumentHeader(template)) { + processedParams.header.media_name = state.mediaName ?? ''; + } + } + + if (template.buttons && template.buttons.length > 0) { + // Sparse array indexed by button position. + const buttons: TemplateButtonParam[] = []; + template.buttons.forEach(button => { + buttons[button.index] = + button.type === 'url' + ? { + type: 'url', + parameter: state.buttonValues[button.index] ?? '', + url: button.url, + variables: button.variables, + } + : { + type: 'copy_code', + parameter: state.buttonValues[button.index] ?? '', + }; + }); + processedParams.buttons = buttons; + } + + return { + name: template.name, + category: template.category, + language: template.language, + namespace: template.namespace, + processed_params: processedParams, + }; +}; + +const buildTwilioParams = ( + template: NormalizedTemplate, + state: TemplateFormState +): TemplateSendParams => { + const processedParams: TwilioProcessedParams = {}; + template.variables.forEach(key => { + processedParams[key] = state.bodyValues[key] ?? ''; + }); + if (template.mediaVariableKey) { + processedParams[template.mediaVariableKey] = state.mediaUrl + ? extractFilenameFromUrl(state.mediaUrl) + : ''; + } + return { + name: template.name, + language: template.language, + processed_params: processedParams, + }; +}; + +export const buildTemplateParams = ( + template: NormalizedTemplate, + state: TemplateFormState +): TemplateSendParams => { + return template.platform === 'twilio' + ? buildTwilioParams(template, state) + : buildWhatsAppParams(template, state); +}; + +// Renders the outgoing message body with the values the agent typed in. +export const renderTemplateMessage = ( + template: NormalizedTemplate, + state: TemplateFormState +): string => { + const values: Record = { ...state.bodyValues }; + if (template.platform === 'twilio' && template.mediaVariableKey) { + values[template.mediaVariableKey] = state.mediaUrl; + } + return renderTemplatePreview(template.body, values); +}; + +export const buildTemplateSendPayload = ( + template: NormalizedTemplate, + state: TemplateFormState +): { message: string; templateParams: TemplateSendParams } => { + return { + message: renderTemplateMessage(template, state), + templateParams: buildTemplateParams(template, state), + }; +}; + +export { MEDIA_FORMATS }; diff --git a/src/types/template.ts b/src/types/template.ts new file mode 100644 index 0000000..38e40a1 --- /dev/null +++ b/src/types/template.ts @@ -0,0 +1,137 @@ +export type WhatsAppTemplateHeaderFormat = + | 'TEXT' + | 'IMAGE' + | 'VIDEO' + | 'DOCUMENT' + | 'LOCATION'; + +export type WhatsAppTemplateButton = { + type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE' | string; + text?: string; + url?: string; + phoneNumber?: string; + example?: string[]; +}; + +export type WhatsAppTemplateComponent = + | { + type: 'HEADER'; + format?: WhatsAppTemplateHeaderFormat; + text?: string; + example?: { headerHandle?: string[]; headerText?: string[] }; + } + | { + type: 'BODY'; + text: string; + example?: { + bodyText?: string[][]; + bodyTextNamedParams?: { paramName: string; example: string }[]; + }; + } + | { type: 'FOOTER'; text: string } + | { type: 'BUTTONS'; buttons: WhatsAppTemplateButton[] }; + +export interface WhatsAppMessageTemplate { + id?: string; + name: string; + status: string; + category: string; + language: string; + namespace?: string; + components: WhatsAppTemplateComponent[]; + parameterFormat?: 'POSITIONAL' | 'NAMED'; +} + +export interface TwilioContentTemplate { + contentSid: string; + friendlyName: string; + language: string; + category?: string; + status: string; + templateType?: string; + mediaType?: string; + body: string; + variables?: Record; + types?: Record>; +} + +export interface TwilioContentTemplates { + templates?: TwilioContentTemplate[]; +} + +export type TemplatePlatform = 'whatsapp' | 'twilio'; + +export interface NormalizedTemplateHeader { + format: WhatsAppTemplateHeaderFormat; + text?: string; +} + +// Button parameters that the in-conversation form collects values for: only URL +// buttons that embed a `{{ }}` variable and COPY_CODE buttons require a parameter. +export interface NormalizedTemplateButton { + index: number; + type: 'url' | 'copy_code'; + url?: string; + variables?: string[]; +} + +export interface NormalizedTemplate { + id: string; + name: string; + platform: TemplatePlatform; + language: string; + category?: string; + namespace?: string; + body: string; + variables: string[]; + parameterFormat?: 'POSITIONAL' | 'NAMED'; + header?: NormalizedTemplateHeader; + actions?: string[]; + buttons?: NormalizedTemplateButton[]; + // Twilio media templates carry the media variable inside `types['twilio/media']` + // rather than in a header component. + isMediaTemplate?: boolean; + mediaVariableKey?: string | null; + templateMediaUrl?: string; +} + +export interface TemplateButtonParam { + type: 'url' | 'copy_code'; + parameter: string; + url?: string; + variables?: string[]; +} + +// WhatsApp payload shape: nested body/header/buttons. +export interface WhatsAppProcessedParams { + body?: Record; + header?: { + media_url: string; + media_type: string; + media_name?: string; + }; + buttons?: TemplateButtonParam[]; +} + +// Twilio payload shape: a flat map keyed by variable token. +export type TwilioProcessedParams = Record; + +export interface TemplateSendParams { + name: string; + category?: string; + language: string; + namespace?: string; + processed_params: WhatsAppProcessedParams | TwilioProcessedParams; +} + +// Mutable form state collected by the in-conversation template form. +export interface TemplateFormState { + // body variable values keyed by variable token + bodyValues: Record; + // WhatsApp media header URL / Twilio media variable value + mediaUrl: string; + // WhatsApp document header filename (optional) + mediaName: string; + // WhatsApp button parameters keyed by button index + buttonValues: Record; +} diff --git a/src/url.ts b/src/url.ts index 91aa012..e85056b 100644 --- a/src/url.ts +++ b/src/url.ts @@ -51,6 +51,26 @@ export const isSameHost = ( } }; +/** + * Extracts a filename from a URL. + * Falls back to a regex match (and finally the original string) when the URL + * cannot be parsed by the `URL` constructor. + * + * @param {string} url - URL to extract the filename from. + * @returns {string} The filename, or the original input if none can be derived. + */ +export const extractFilenameFromUrl = (url: string): string => { + if (!url || typeof url !== 'string') return url; + try { + const urlObj = new URL(url); + const filename = urlObj.pathname.split('/').pop(); + return filename || url; + } catch { + const match = url.match(/\/([^/?#]+)(?:[?#]|$)/); + return match ? match[1] : url; + } +}; + /** * Check if a string is a valid domain name. * An empty string is allowed and considered valid. diff --git a/test/template.test.ts b/test/template.test.ts new file mode 100644 index 0000000..13ab260 --- /dev/null +++ b/test/template.test.ts @@ -0,0 +1,305 @@ +import { + extractVariables, + renderTemplatePreview, + renderTemplateLabel, + buildPreviewSegments, + isSendableTemplate, + hasMediaHeader, + isDocumentHeader, + getMediaType, + getHeaderSubtitle, + normalizeWhatsApp, + normalizeTwilio, + getTemplates, + filterTemplatesByQuery, + createEmptyFormState, + isTemplateComplete, + buildTemplateParams, + renderTemplateMessage, + buildTemplateSendPayload, +} from '../src/template'; +import { + NormalizedTemplate, + WhatsAppMessageTemplate, + TwilioContentTemplate, +} from '../src/types/template'; + +const whatsAppTemplate = ( + overrides: Partial = {} +): WhatsAppMessageTemplate => ({ + name: 'order_update', + status: 'approved', + category: 'MARKETING', + language: 'en', + components: [{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} shipped.' }], + ...overrides, +}); + +describe('#extractVariables', () => { + it('returns ordered, de-duplicated variable keys', () => { + expect( + extractVariables('Hi {{name}}, again {{name}} and {{ order }}') + ).toEqual(['name', 'order']); + }); + + it('returns an empty array for empty input', () => { + expect(extractVariables('')).toEqual([]); + }); +}); + +describe('#renderTemplatePreview / #renderTemplateLabel', () => { + it('fills provided values and keeps placeholders for missing ones', () => { + expect( + renderTemplatePreview('Hi {{name}}, order {{id}}', { name: 'Sam' }) + ).toBe('Hi Sam, order {{id}}'); + }); + + it('renders labels with spaced braces', () => { + expect(renderTemplateLabel('Hi {{name}}')).toBe('Hi { name }'); + }); +}); + +describe('#buildPreviewSegments', () => { + it('marks filled and unfilled segments', () => { + expect(buildPreviewSegments('Hi {{name}}!', { name: 'Sam' })).toEqual([ + { text: 'Hi ', filled: false }, + { text: 'Sam', filled: true }, + { text: '!', filled: false }, + ]); + }); +}); + +describe('#isSendableTemplate', () => { + it('accepts an approved, supported template', () => { + expect(isSendableTemplate(whatsAppTemplate())).toBe(true); + }); + + it('rejects non-approved templates (case-insensitive)', () => { + expect(isSendableTemplate(whatsAppTemplate({ status: 'PENDING' }))).toBe( + false + ); + expect(isSendableTemplate(whatsAppTemplate({ status: 'APPROVED' }))).toBe( + true + ); + }); + + it('rejects authentication, csat, and unsupported components', () => { + expect( + isSendableTemplate(whatsAppTemplate({ category: 'AUTHENTICATION' })) + ).toBe(false); + expect( + isSendableTemplate( + whatsAppTemplate({ name: 'customer_satisfaction_survey_1' }) + ) + ).toBe(false); + expect( + isSendableTemplate( + whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'LOCATION' }, + { type: 'BODY', text: 'hi' }, + ], + }) + ) + ).toBe(false); + }); +}); + +describe('#normalizeWhatsApp', () => { + it('extracts body, header, actions and button params', () => { + const normalized = normalizeWhatsApp( + whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'Hi {{1}}' }, + { + type: 'BUTTONS', + buttons: [ + { type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' }, + { type: 'COPY_CODE', text: 'Copy' }, + { type: 'QUICK_REPLY', text: 'Yes' }, + ], + }, + ], + }) + ); + expect(normalized.body).toBe('Hi {{1}}'); + expect(normalized.variables).toEqual(['1']); + expect(hasMediaHeader(normalized)).toBe(true); + expect(getMediaType(normalized)).toBe('image'); + expect(getHeaderSubtitle(normalized)).toBe('Image Header'); + expect(normalized.actions).toEqual(['Track', 'Copy', 'Yes']); + expect(normalized.buttons).toEqual([ + { index: 0, type: 'url', url: 'https://x.com/{{1}}', variables: ['1'] }, + { index: 1, type: 'copy_code' }, + ]); + }); +}); + +describe('#normalizeTwilio', () => { + const twilio: TwilioContentTemplate = { + contentSid: 'HX1', + friendlyName: 'media_demo', + language: 'en', + status: 'approved', + templateType: 'media', + body: 'Hi {{1}}', + types: { 'twilio/media': { media: ['https://x.com/{{2}}'] } }, + }; + + it('returns null for non-approved templates (case-sensitive)', () => { + expect(normalizeTwilio({ ...twilio, status: 'Approved' })).toBeNull(); + }); + + it('extracts media variable key for media templates', () => { + const normalized = normalizeTwilio(twilio)!; + expect(normalized.isMediaTemplate).toBe(true); + expect(normalized.mediaVariableKey).toBe('2'); + expect(normalized.templateMediaUrl).toBe('https://x.com/{{2}}'); + }); +}); + +describe('#getTemplates / #filterTemplatesByQuery', () => { + it('combines sendable whatsapp and approved twilio templates', () => { + const templates = getTemplates( + [whatsAppTemplate(), whatsAppTemplate({ status: 'pending' })], + [ + { + contentSid: 'HX1', + friendlyName: 'twilio_one', + language: 'en', + status: 'approved', + body: 'hi', + }, + ] + ); + expect(templates.map(t => t.name)).toEqual(['order_update', 'twilio_one']); + expect(filterTemplatesByQuery(templates, 'twilio')).toHaveLength(1); + expect(filterTemplatesByQuery(templates, '')).toHaveLength(2); + }); +}); + +describe('#isTemplateComplete', () => { + it('requires body, media and button inputs for whatsapp', () => { + const template: NormalizedTemplate = { + id: 'a', + name: 'a', + platform: 'whatsapp', + language: 'en', + body: 'Hi {{1}}', + variables: ['1'], + header: { format: 'IMAGE' }, + buttons: [{ index: 0, type: 'copy_code' }], + }; + const state = createEmptyFormState(); + expect(isTemplateComplete(template, state)).toBe(false); + state.bodyValues['1'] = 'Sam'; + state.mediaUrl = 'https://x.com/a.png'; + state.buttonValues[0] = 'CODE'; + expect(isTemplateComplete(template, state)).toBe(true); + }); + + it('is complete when twilio has no variables and no media', () => { + const template: NormalizedTemplate = { + id: 'b', + name: 'b', + platform: 'twilio', + language: 'en', + body: 'hello', + variables: [], + }; + expect(isTemplateComplete(template, createEmptyFormState())).toBe(true); + }); +}); + +describe('#buildTemplateParams / #buildTemplateSendPayload', () => { + it('builds nested whatsapp processed params with sparse buttons', () => { + const template: NormalizedTemplate = { + id: 'a', + name: 'order_update', + platform: 'whatsapp', + language: 'en', + category: 'MARKETING', + namespace: 'ns', + body: 'Hi {{1}}', + variables: ['1'], + header: { format: 'DOCUMENT' }, + buttons: [ + { index: 1, type: 'url', url: 'https://x.com/{{1}}', variables: ['1'] }, + ], + }; + const state = createEmptyFormState(); + state.bodyValues['1'] = 'Sam'; + state.mediaUrl = 'https://x.com/invoice.pdf'; + state.mediaName = 'invoice.pdf'; + state.buttonValues[1] = 'TRACK'; + + const params = buildTemplateParams(template, state); + expect(params).toEqual({ + name: 'order_update', + category: 'MARKETING', + language: 'en', + namespace: 'ns', + processed_params: { + body: { '1': 'Sam' }, + header: { + media_url: 'https://x.com/invoice.pdf', + media_type: 'document', + media_name: 'invoice.pdf', + }, + buttons: [ + undefined, + { + type: 'url', + parameter: 'TRACK', + url: 'https://x.com/{{1}}', + variables: ['1'], + }, + ], + }, + }); + expect(isDocumentHeader(template)).toBe(true); + }); + + it('builds flat twilio params and resolves media filename', () => { + const template: NormalizedTemplate = { + id: 'b', + name: 'media_demo', + platform: 'twilio', + language: 'en', + body: 'Hi {{1}}', + variables: ['1'], + isMediaTemplate: true, + mediaVariableKey: '2', + }; + const state = createEmptyFormState(); + state.bodyValues['1'] = 'Sam'; + state.mediaUrl = 'https://x.com/path/photo.png?token=1'; + + const payload = buildTemplateSendPayload(template, state); + expect(payload.message).toBe('Hi Sam'); + expect(payload.templateParams.processed_params).toEqual({ + '1': 'Sam', + '2': 'photo.png', + }); + }); +}); + +describe('#renderTemplateMessage', () => { + it('injects twilio media url into the rendered message', () => { + const template: NormalizedTemplate = { + id: 'b', + name: 'b', + platform: 'twilio', + language: 'en', + body: 'See {{2}}', + variables: [], + mediaVariableKey: '2', + }; + const state = createEmptyFormState(); + state.mediaUrl = 'https://x.com/a.png'; + expect(renderTemplateMessage(template, state)).toBe( + 'See https://x.com/a.png' + ); + }); +}); From 804b7dbfcec88aabc6da1cefae7a71f467601406 Mon Sep 17 00:00:00 2001 From: Devi R Date: Mon, 29 Jun 2026 12:08:26 +0530 Subject: [PATCH 2/5] fix(template): satisfy tsdx lint (prettier + export syntax) --- src/index.ts | 20 +------------------- src/template.ts | 17 ++++++++++++----- src/types/template.ts | 2 ++ 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index c0e4c0e..1c9c4fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,22 +124,4 @@ export { MEDIA_FORMATS, }; -export type { - WhatsAppTemplateHeaderFormat, - WhatsAppTemplateButton, - WhatsAppTemplateComponent, - WhatsAppMessageTemplate, - TwilioContentTemplate, - TwilioContentTemplates, - TemplatePlatform, - NormalizedTemplateHeader, - NormalizedTemplateButton, - NormalizedTemplate, - TemplateButtonParam, - WhatsAppProcessedParams, - TwilioProcessedParams, - TemplateSendParams, - TemplateFormState, -} from './types/template'; - -export type { PreviewSegment } from './template'; +export * from './types/template'; diff --git a/src/template.ts b/src/template.ts index caed2e1..3c640c8 100644 --- a/src/template.ts +++ b/src/template.ts @@ -3,6 +3,7 @@ import { NormalizedTemplate, NormalizedTemplateButton, NormalizedTemplateHeader, + PreviewSegment, TemplateButtonParam, TemplateFormState, TemplateSendParams, @@ -66,11 +67,12 @@ export const renderTemplatePreview = ( export const renderTemplateLabel = (body: string): string => { if (!body) return ''; - return body.replace(VARIABLE_REGEX, (_match, rawKey) => `{ ${rawKey.trim()} }`); + return body.replace( + VARIABLE_REGEX, + (_match, rawKey) => `{ ${rawKey.trim()} }` + ); }; -export type PreviewSegment = { text: string; filled: boolean }; - export const buildPreviewSegments = ( body: string, values: Record @@ -82,7 +84,10 @@ export const buildPreviewSegments = ( let match: RegExpExecArray | null; while ((match = regex.exec(body)) !== null) { if (match.index > lastIndex) { - segments.push({ text: body.slice(lastIndex, match.index), filled: false }); + segments.push({ + text: body.slice(lastIndex, match.index), + filled: false, + }); } const key = match[1].trim(); const value = values[key]; @@ -324,7 +329,9 @@ export const isTemplateComplete = ( if (template.variables.some(key => !isFilled(state.bodyValues[key]))) return false; if ( - template.buttons?.some(button => !isFilled(state.buttonValues[button.index])) + template.buttons?.some( + button => !isFilled(state.buttonValues[button.index]) + ) ) return false; return true; diff --git a/src/types/template.ts b/src/types/template.ts index 38e40a1..3d7fe5e 100644 --- a/src/types/template.ts +++ b/src/types/template.ts @@ -61,6 +61,8 @@ export interface TwilioContentTemplates { export type TemplatePlatform = 'whatsapp' | 'twilio'; +export type PreviewSegment = { text: string; filled: boolean }; + export interface NormalizedTemplateHeader { format: WhatsAppTemplateHeaderFormat; text?: string; From 3adb35fa176bc50f3f5feb2d7020d5a192e3b915 Mon Sep 17 00:00:00 2001 From: Devi R Date: Mon, 29 Jun 2026 12:09:25 +0530 Subject: [PATCH 3/5] fix: correct .gitignore coverage/package-lock entries --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cb0d28c..fea09d8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules dist docs -coveragepackage-lock.json +coverage +package-lock.json From 3a3d8e7871d13e4a738bb84e037ef337f3e4e2d8 Mon Sep 17 00:00:00 2001 From: Devi R Date: Mon, 29 Jun 2026 12:34:44 +0530 Subject: [PATCH 4/5] chore: raise size-limit to 13 KB for template module --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0b83cd8..b481ab4 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "size-limit": [ { "path": "dist/utils.cjs.production.min.js", - "limit": "10 KB" + "limit": "13 KB" }, { "path": "dist/utils.esm.js", - "limit": "10 KB" + "limit": "13 KB" } ], "devDependencies": { From 347cd5fdb8940b8c6db33224a7691f7c7bbef74b Mon Sep 17 00:00:00 2001 From: Devi R Date: Mon, 6 Jul 2026 16:15:59 +0530 Subject: [PATCH 5/5] refactor(template): expose neutral raw-template/processed_params API --- src/index.ts | 50 +++-- src/template.ts | 463 ++++++++++++++---------------------------- src/types/template.ts | 89 ++------ test/template.test.ts | 354 ++++++++++++++------------------ 4 files changed, 345 insertions(+), 611 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1c9c4fe..28eba18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,25 +48,24 @@ import { } from './fileUploadRules'; import { + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, extractVariables, renderTemplatePreview, - renderTemplateLabel, - buildPreviewSegments, isSendableTemplate, hasMediaHeader, isDocumentHeader, getMediaType, - getHeaderSubtitle, - normalizeWhatsApp, - normalizeTwilio, - getTemplates, - filterTemplatesByQuery, - createEmptyFormState, - isTemplateComplete, - buildTemplateParams, - renderTemplateMessage, - buildTemplateSendPayload, - MEDIA_FORMATS, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, } from './template'; export { @@ -103,25 +102,24 @@ export { formatNumber, getAllowedFileTypesByChannel, getMaxUploadSizeByChannel, + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, extractVariables, renderTemplatePreview, - renderTemplateLabel, - buildPreviewSegments, isSendableTemplate, hasMediaHeader, isDocumentHeader, getMediaType, - getHeaderSubtitle, - normalizeWhatsApp, - normalizeTwilio, - getTemplates, - filterTemplatesByQuery, - createEmptyFormState, - isTemplateComplete, - buildTemplateParams, - renderTemplateMessage, - buildTemplateSendPayload, - MEDIA_FORMATS, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, }; export * from './types/template'; diff --git a/src/template.ts b/src/template.ts index 3c640c8..10f42e0 100644 --- a/src/template.ts +++ b/src/template.ts @@ -1,12 +1,6 @@ import { extractFilenameFromUrl } from './url'; import { - NormalizedTemplate, - NormalizedTemplateButton, - NormalizedTemplateHeader, - PreviewSegment, TemplateButtonParam, - TemplateFormState, - TemplateSendParams, TwilioContentTemplate, TwilioProcessedParams, WhatsAppMessageTemplate, @@ -14,29 +8,44 @@ import { WhatsAppTemplateComponent, } from './types/template'; -const MEDIA_FORMATS = new Set(['IMAGE', 'VIDEO', 'DOCUMENT']); -// Component types that aren't supported when sending a template. -const UNSUPPORTED_COMPONENT_TYPES = new Set([ +// Header formats that carry a media attachment. +export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT']; + +export const COMPONENT_TYPES = { + HEADER: 'HEADER', + BODY: 'BODY', + BUTTONS: 'BUTTONS', +} as const; + +// Component types that can't be sent from the composer. +const UNSUPPORTED_COMPONENT_TYPES = [ 'LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST', -]); +]; + const TWILIO_MEDIA_TEMPLATE_TYPE = 'media'; -const VARIABLE_REGEX = /\{\{([^}]+)\}\}/g; +const VARIABLE_REGEX = /{{([^}]+)}}/g; -const findComponent = ( +export const findComponentByType = < + T extends WhatsAppTemplateComponent['type'] +>( template: WhatsAppMessageTemplate, type: T -): Extract | undefined => { - return template.components?.find(component => component.type === type) as +): Extract | undefined => + template.components?.find(component => component.type === type) as | Extract | undefined; -}; -const isCsatTemplate = (name: string) => +// Strips the surrounding braces from a `{{token}}` match. +export const processVariable = (str: string): string => + str.replace(/{{|}}/g, ''); + +const isCsatTemplate = (name: string): boolean => name.startsWith('customer_satisfaction_survey'); +// Ordered, de-duplicated list of variable tokens found in a body string. export const extractVariables = (body: string): string[] => { if (!body) return []; const regex = new RegExp(VARIABLE_REGEX.source, 'g'); @@ -53,6 +62,7 @@ export const extractVariables = (body: string): string[] => { return ordered; }; +// Replaces `{{token}}` occurrences with values, keeping the token when unset. export const renderTemplatePreview = ( body: string, values: Record @@ -65,47 +75,8 @@ export const renderTemplatePreview = ( }); }; -export const renderTemplateLabel = (body: string): string => { - if (!body) return ''; - return body.replace( - VARIABLE_REGEX, - (_match, rawKey) => `{ ${rawKey.trim()} }` - ); -}; - -export const buildPreviewSegments = ( - body: string, - values: Record -): PreviewSegment[] => { - if (!body) return []; - const segments: PreviewSegment[] = []; - const regex = new RegExp(VARIABLE_REGEX.source, 'g'); - let lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = regex.exec(body)) !== null) { - if (match.index > lastIndex) { - segments.push({ - text: body.slice(lastIndex, match.index), - filled: false, - }); - } - const key = match[1].trim(); - const value = values[key]; - if (value && value.length > 0) { - segments.push({ text: value, filled: true }); - } else { - segments.push({ text: `{{${key}}}`, filled: false }); - } - lastIndex = regex.lastIndex; - } - if (lastIndex < body.length) { - segments.push({ text: body.slice(lastIndex), filled: false }); - } - return segments; -}; - /** - * Filters WhatsApp templates down to the ones that can be sent: + * Whether a WhatsApp template can be sent from the composer: * - requires status + components * - status (case-insensitive) === 'approved' * - category !== 'AUTHENTICATION' @@ -122,7 +93,7 @@ export const isSendableTemplate = ( const hasUnsupportedComponents = template.components.some( component => - UNSUPPORTED_COMPONENT_TYPES.has(component.type) || + UNSUPPORTED_COMPONENT_TYPES.indexOf(component.type) !== -1 || (component.type === 'HEADER' && component.format === 'LOCATION') ); if (hasUnsupportedComponents) return false; @@ -130,315 +101,181 @@ export const isSendableTemplate = ( return true; }; -export const hasMediaHeader = (template: NormalizedTemplate): boolean => { - return template.header ? MEDIA_FORMATS.has(template.header.format) : false; -}; - -export const isDocumentHeader = (template: NormalizedTemplate): boolean => { - return template.header?.format === 'DOCUMENT'; -}; - -export const getMediaType = (template: NormalizedTemplate): string => { - return template.header ? template.header.format.toLowerCase() : ''; -}; - -const headerLabelMap: Record = { - IMAGE: 'Image Header', - VIDEO: 'Video Header', - DOCUMENT: 'Document Header', - LOCATION: 'Location Header', +export const hasMediaHeader = (template: WhatsAppMessageTemplate): boolean => { + const header = findComponentByType(template, 'HEADER'); + return header?.format ? MEDIA_FORMATS.indexOf(header.format) !== -1 : false; }; -export const getHeaderSubtitle = ( - template: NormalizedTemplate -): string | undefined => { - const header = template.header; - if (!header) return undefined; - if (header.format === 'TEXT') return header.text; - return headerLabelMap[header.format]; +export const isDocumentHeader = ( + template: WhatsAppMessageTemplate +): boolean => { + const header = findComponentByType(template, 'HEADER'); + return header?.format?.toLowerCase() === 'document'; }; -const extractHeader = ( - template: WhatsAppMessageTemplate -): NormalizedTemplateHeader | undefined => { - const header = findComponent(template, 'HEADER'); - if (!header) return undefined; - const format = header.format; - if (!format) return undefined; - return { format, text: header.text }; +export const getMediaType = (template: WhatsAppMessageTemplate): string => { + const header = findComponentByType(template, 'HEADER'); + return header?.format ? header.format.toLowerCase() : ''; }; -// Labels for the list-row action chips (display only). -const extractActions = ( - template: WhatsAppMessageTemplate -): string[] | undefined => { - const buttons = findComponent(template, 'BUTTONS'); - if (!buttons?.buttons?.length) return undefined; - const labels = buttons.buttons - .map(button => button.text?.trim()) - .filter((text): text is string => Boolean(text)); - return labels.length > 0 ? labels : undefined; +const bodyHasVariables = (template: WhatsAppMessageTemplate): boolean => { + const body = findComponentByType(template, 'BODY'); + return body ? body.text.match(VARIABLE_REGEX) !== null : false; }; -// Collect only the buttons that require a user-supplied parameter (URL buttons -// with a `{{ }}` variable, and COPY_CODE buttons), preserving their positional index. -const extractButtonParams = ( +// Collects the URL/COPY_CODE buttons that require a user-supplied parameter, +// preserving their positional index (sparse array). +const buildButtonParams = ( template: WhatsAppMessageTemplate -): NormalizedTemplateButton[] | undefined => { +): TemplateButtonParam[] | undefined => { const buttonComponents = (template.components || []).filter( component => component.type === 'BUTTONS' ); - const result: NormalizedTemplateButton[] = []; + const buttons: TemplateButtonParam[] = []; + let found = false; buttonComponents.forEach(component => { if (component.type !== 'BUTTONS' || !component.buttons) return; component.buttons.forEach((button, index) => { if (button.type === 'URL' && button.url && button.url.includes('{{')) { const buttonVars = button.url.match(VARIABLE_REGEX) || []; if (buttonVars.length > 0) { - result.push({ - index, + found = true; + buttons[index] = { type: 'url', + parameter: '', url: button.url, - variables: buttonVars.map(v => v.replace(/{{|}}/g, '')), - }); + variables: buttonVars.map(processVariable), + }; } } if (button.type === 'COPY_CODE') { - result.push({ index, type: 'copy_code' }); + found = true; + buttons[index] = { type: 'copy_code', parameter: '' }; } }); }); - return result.length > 0 ? result : undefined; -}; - -export const normalizeWhatsApp = ( - template: WhatsAppMessageTemplate -): NormalizedTemplate => { - const body = findComponent(template, 'BODY'); - const bodyText = body?.text ?? ''; - return { - id: template.name, - name: template.name, - platform: 'whatsapp', - language: template.language, - category: template.category, - namespace: template.namespace, - body: bodyText, - variables: extractVariables(bodyText), - parameterFormat: template.parameterFormat, - header: extractHeader(template), - actions: extractActions(template), - buttons: extractButtonParams(template), - }; -}; - -const getTwilioMediaUrl = (template: TwilioContentTemplate): string => { - return template.types?.['twilio/media']?.media?.[0] ?? ''; -}; - -export const normalizeTwilio = ( - template: TwilioContentTemplate -): NormalizedTemplate | null => { - // Twilio templates filter with an exact (case-sensitive) `status === 'approved'`. - if (template.status !== 'approved') return null; - const body = template.body || ''; - const isMediaTemplate = template.templateType === TWILIO_MEDIA_TEMPLATE_TYPE; - const mediaUrl = isMediaTemplate ? getTwilioMediaUrl(template) : ''; - const mediaVariableKey = mediaUrl - ? mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null - : null; - return { - id: template.contentSid, - name: template.friendlyName, - platform: 'twilio', - language: template.language, - category: template.category, - body, - variables: extractVariables(body), - isMediaTemplate, - mediaVariableKey, - templateMediaUrl: mediaUrl, - }; + return found ? buttons : undefined; }; /** - * Normalizes the raw WhatsApp + Twilio template arrays carried by an inbox into - * a single list of sendable `NormalizedTemplate`s. + * Builds the empty WhatsApp processed_params scaffold for a template: body keys, + * a media header block, and the button parameters that need filling. */ -export const getTemplates = ( - messageTemplates: WhatsAppMessageTemplate[] | undefined, - contentTemplates: TwilioContentTemplate[] | undefined -): NormalizedTemplate[] => { - const whatsapp = (messageTemplates || []) - .filter(isSendableTemplate) - .map(normalizeWhatsApp); - const twilio = (contentTemplates || []) - .map(normalizeTwilio) - .filter((entry): entry is NormalizedTemplate => entry !== null); - return [...whatsapp, ...twilio]; -}; +export const buildWhatsAppProcessedParams = ( + template: WhatsAppMessageTemplate +): WhatsAppProcessedParams => { + const params: WhatsAppProcessedParams = {}; -// Search by template name only (WhatsApp `name`, Twilio `friendly_name`). -export const filterTemplatesByQuery = ( - templates: NormalizedTemplate[], - query: string -): NormalizedTemplate[] => { - const trimmed = query.trim().toLowerCase(); - if (!trimmed) return templates; - return templates.filter(template => - template.name.toLowerCase().includes(trimmed) - ); -}; + const body = findComponentByType(template, 'BODY'); + if (!body) return params; -export const createEmptyFormState = (): TemplateFormState => ({ - bodyValues: {}, - mediaUrl: '', - mediaName: '', - buttonValues: {}, -}); + const matchedVariables = body.text.match(VARIABLE_REGEX); + if (matchedVariables) { + const bodyParams: Record = {}; + matchedVariables.forEach(variable => { + bodyParams[processVariable(variable)] = ''; + }); + params.body = bodyParams; + } -// Empty/missing values are invalid via a plain truthiness check, without trimming. -const isFilled = (value: string | undefined): boolean => Boolean(value); + if (hasMediaHeader(template)) { + const format = getMediaType(template); + params.header = { media_url: '', media_type: format }; + if (format === 'document') params.header.media_name = ''; + } + + const buttons = buildButtonParams(template); + if (buttons) params.buttons = buttons; -// Whether the Twilio media variable input is in play. -const hasTwilioMediaVariable = (template: NormalizedTemplate): boolean => { - return Boolean(template.isMediaTemplate && template.mediaVariableKey); + return params; }; /** - * Whether every required input for the template has been filled, for both platforms. + * Whether every required WhatsApp input has been filled. A template with no body + * variables and no media header is considered complete even if it has buttons + * (mirrors the composer's validation). */ -export const isTemplateComplete = ( - template: NormalizedTemplate, - state: TemplateFormState +export const isWhatsAppComplete = ( + template: WhatsAppMessageTemplate, + processedParams: WhatsAppProcessedParams ): boolean => { - if (template.platform === 'twilio') { - const mediaVariable = hasTwilioMediaVariable(template); - if (template.variables.length === 0 && !mediaVariable) return true; - if (template.variables.some(key => !isFilled(state.bodyValues[key]))) - return false; - if (mediaVariable && !isFilled(state.mediaUrl)) return false; - return true; - } - + const hasVariables = bodyHasVariables(template); const media = hasMediaHeader(template); - // Early-returns valid when there are no variables and no media header, - // even if the template has buttons. - if (template.variables.length === 0 && !media) return true; - if (media && !isFilled(state.mediaUrl)) return false; - if (template.variables.some(key => !isFilled(state.bodyValues[key]))) - return false; - if ( - template.buttons?.some( - button => !isFilled(state.buttonValues[button.index]) - ) - ) - return false; - return true; -}; -const buildWhatsAppParams = ( - template: NormalizedTemplate, - state: TemplateFormState -): TemplateSendParams => { - const processedParams: WhatsAppProcessedParams = {}; + if (!hasVariables && !media) return true; - if (template.variables.length > 0) { - const body: Record = {}; - template.variables.forEach(key => { - body[key] = state.bodyValues[key] ?? ''; - }); - processedParams.body = body; - } + if (media && !processedParams.header?.media_url) return false; - if (hasMediaHeader(template)) { - processedParams.header = { - media_url: state.mediaUrl, - media_type: getMediaType(template), - }; - if (isDocumentHeader(template)) { - processedParams.header.media_name = state.mediaName ?? ''; - } + if (hasVariables && processedParams.body) { + const hasEmptyBodyVariable = Object.keys(processedParams.body).some( + key => !processedParams.body?.[key] + ); + if (hasEmptyBodyVariable) return false; } - if (template.buttons && template.buttons.length > 0) { - // Sparse array indexed by button position. - const buttons: TemplateButtonParam[] = []; - template.buttons.forEach(button => { - buttons[button.index] = - button.type === 'url' - ? { - type: 'url', - parameter: state.buttonValues[button.index] ?? '', - url: button.url, - variables: button.variables, - } - : { - type: 'copy_code', - parameter: state.buttonValues[button.index] ?? '', - }; - }); - processedParams.buttons = buttons; + if (processedParams.buttons) { + const hasEmptyButtonParameter = processedParams.buttons.some( + button => button && !button.parameter + ); + if (hasEmptyButtonParameter) return false; } - return { - name: template.name, - category: template.category, - language: template.language, - namespace: template.namespace, - processed_params: processedParams, - }; + return true; }; -const buildTwilioParams = ( - template: NormalizedTemplate, - state: TemplateFormState -): TemplateSendParams => { - const processedParams: TwilioProcessedParams = {}; - template.variables.forEach(key => { - processedParams[key] = state.bodyValues[key] ?? ''; - }); - if (template.mediaVariableKey) { - processedParams[template.mediaVariableKey] = state.mediaUrl - ? extractFilenameFromUrl(state.mediaUrl) - : ''; - } - return { - name: template.name, - language: template.language, - processed_params: processedParams, - }; -}; +export const isTwilioMediaTemplate = ( + template: TwilioContentTemplate +): boolean => template.template_type === TWILIO_MEDIA_TEMPLATE_TYPE; -export const buildTemplateParams = ( - template: NormalizedTemplate, - state: TemplateFormState -): TemplateSendParams => { - return template.platform === 'twilio' - ? buildTwilioParams(template, state) - : buildWhatsAppParams(template, state); +export const getTwilioMediaUrl = (template: TwilioContentTemplate): string => + template.types?.['twilio/media']?.media?.[0] ?? ''; + +// The variable token (e.g. '1') embedded in a Twilio media URL, if any. +export const getTwilioMediaVariableKey = ( + template: TwilioContentTemplate +): string | null => { + if (!isTwilioMediaTemplate(template)) return null; + const mediaUrl = getTwilioMediaUrl(template); + if (!mediaUrl) return null; + return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; }; -// Renders the outgoing message body with the values the agent typed in. -export const renderTemplateMessage = ( - template: NormalizedTemplate, - state: TemplateFormState -): string => { - const values: Record = { ...state.bodyValues }; - if (template.platform === 'twilio' && template.mediaVariableKey) { - values[template.mediaVariableKey] = state.mediaUrl; - } - return renderTemplatePreview(template.body, values); +// Builds the empty Twilio processed_params: one key per body variable plus the +// media variable when present. +export const buildTwilioProcessedParams = ( + template: TwilioContentTemplate +): TwilioProcessedParams => { + const params: TwilioProcessedParams = {}; + extractVariables(template.body || '').forEach(variable => { + params[variable] = ''; + }); + const mediaKey = getTwilioMediaVariableKey(template); + if (mediaKey) params[mediaKey] = ''; + return params; }; -export const buildTemplateSendPayload = ( - template: NormalizedTemplate, - state: TemplateFormState -): { message: string; templateParams: TemplateSendParams } => { - return { - message: renderTemplateMessage(template, state), - templateParams: buildTemplateParams(template, state), - }; +export const isTwilioComplete = ( + template: TwilioContentTemplate, + processedParams: TwilioProcessedParams +): boolean => { + const variables = extractVariables(template.body || ''); + const mediaKey = getTwilioMediaVariableKey(template); + + if (variables.length === 0 && !mediaKey) return true; + if (variables.some(variable => !processedParams[variable])) return false; + if (mediaKey && !processedParams[mediaKey]) return false; + return true; }; -export { MEDIA_FORMATS }; +// Reduces the Twilio media variable value to a filename before sending. +export const applyTwilioMediaFilename = ( + template: TwilioContentTemplate, + processedParams: TwilioProcessedParams +): TwilioProcessedParams => { + const mediaKey = getTwilioMediaVariableKey(template); + const result = { ...processedParams }; + if (mediaKey && result[mediaKey]) { + result[mediaKey] = extractFilenameFromUrl(result[mediaKey]); + } + return result; +}; diff --git a/src/types/template.ts b/src/types/template.ts index 3d7fe5e..972d2e9 100644 --- a/src/types/template.ts +++ b/src/types/template.ts @@ -1,3 +1,6 @@ +// Neutral WhatsApp/Twilio template types shared by web and mobile. +// Shapes follow the backend API contract: raw templates in, processed_params out. + export type WhatsAppTemplateHeaderFormat = | 'TEXT' | 'IMAGE' @@ -9,7 +12,7 @@ export type WhatsAppTemplateButton = { type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE' | string; text?: string; url?: string; - phoneNumber?: string; + phone_number?: string; example?: string[]; }; @@ -18,16 +21,9 @@ export type WhatsAppTemplateComponent = type: 'HEADER'; format?: WhatsAppTemplateHeaderFormat; text?: string; - example?: { headerHandle?: string[]; headerText?: string[] }; - } - | { - type: 'BODY'; - text: string; - example?: { - bodyText?: string[][]; - bodyTextNamedParams?: { paramName: string; example: string }[]; - }; + example?: Record; } + | { type: 'BODY'; text: string; example?: Record } | { type: 'FOOTER'; text: string } | { type: 'BUTTONS'; buttons: WhatsAppTemplateButton[] }; @@ -39,17 +35,17 @@ export interface WhatsAppMessageTemplate { language: string; namespace?: string; components: WhatsAppTemplateComponent[]; - parameterFormat?: 'POSITIONAL' | 'NAMED'; + parameter_format?: 'POSITIONAL' | 'NAMED'; } export interface TwilioContentTemplate { - contentSid: string; - friendlyName: string; + content_sid: string; + friendly_name: string; language: string; category?: string; status: string; - templateType?: string; - mediaType?: string; + template_type?: string; + media_type?: string; body: string; variables?: Record; types?: Record>; @@ -59,44 +55,7 @@ export interface TwilioContentTemplates { templates?: TwilioContentTemplate[]; } -export type TemplatePlatform = 'whatsapp' | 'twilio'; - -export type PreviewSegment = { text: string; filled: boolean }; - -export interface NormalizedTemplateHeader { - format: WhatsAppTemplateHeaderFormat; - text?: string; -} - -// Button parameters that the in-conversation form collects values for: only URL -// buttons that embed a `{{ }}` variable and COPY_CODE buttons require a parameter. -export interface NormalizedTemplateButton { - index: number; - type: 'url' | 'copy_code'; - url?: string; - variables?: string[]; -} - -export interface NormalizedTemplate { - id: string; - name: string; - platform: TemplatePlatform; - language: string; - category?: string; - namespace?: string; - body: string; - variables: string[]; - parameterFormat?: 'POSITIONAL' | 'NAMED'; - header?: NormalizedTemplateHeader; - actions?: string[]; - buttons?: NormalizedTemplateButton[]; - // Twilio media templates carry the media variable inside `types['twilio/media']` - // rather than in a header component. - isMediaTemplate?: boolean; - mediaVariableKey?: string | null; - templateMediaUrl?: string; -} - +// A single WhatsApp button parameter inside processed_params.buttons. export interface TemplateButtonParam { type: 'url' | 'copy_code'; parameter: string; @@ -104,7 +63,7 @@ export interface TemplateButtonParam { variables?: string[]; } -// WhatsApp payload shape: nested body/header/buttons. +// WhatsApp processed_params: nested body / header / buttons. export interface WhatsAppProcessedParams { body?: Record; header?: { @@ -115,25 +74,7 @@ export interface WhatsAppProcessedParams { buttons?: TemplateButtonParam[]; } -// Twilio payload shape: a flat map keyed by variable token. +// Twilio processed_params: a flat map keyed by variable token. export type TwilioProcessedParams = Record; -export interface TemplateSendParams { - name: string; - category?: string; - language: string; - namespace?: string; - processed_params: WhatsAppProcessedParams | TwilioProcessedParams; -} - -// Mutable form state collected by the in-conversation template form. -export interface TemplateFormState { - // body variable values keyed by variable token - bodyValues: Record; - // WhatsApp media header URL / Twilio media variable value - mediaUrl: string; - // WhatsApp document header filename (optional) - mediaName: string; - // WhatsApp button parameters keyed by button index - buttonValues: Record; -} +export type ProcessedParams = WhatsAppProcessedParams | TwilioProcessedParams; diff --git a/test/template.test.ts b/test/template.test.ts index 13ab260..5dab8dc 100644 --- a/test/template.test.ts +++ b/test/template.test.ts @@ -1,25 +1,23 @@ import { + MEDIA_FORMATS, + findComponentByType, + processVariable, extractVariables, renderTemplatePreview, - renderTemplateLabel, - buildPreviewSegments, isSendableTemplate, hasMediaHeader, isDocumentHeader, getMediaType, - getHeaderSubtitle, - normalizeWhatsApp, - normalizeTwilio, - getTemplates, - filterTemplatesByQuery, - createEmptyFormState, - isTemplateComplete, - buildTemplateParams, - renderTemplateMessage, - buildTemplateSendPayload, + buildWhatsAppProcessedParams, + isWhatsAppComplete, + isTwilioMediaTemplate, + getTwilioMediaUrl, + getTwilioMediaVariableKey, + buildTwilioProcessedParams, + isTwilioComplete, + applyTwilioMediaFilename, } from '../src/template'; import { - NormalizedTemplate, WhatsAppMessageTemplate, TwilioContentTemplate, } from '../src/types/template'; @@ -35,55 +33,65 @@ const whatsAppTemplate = ( ...overrides, }); -describe('#extractVariables', () => { +const twilioTemplate = ( + overrides: Partial = {} +): TwilioContentTemplate => ({ + content_sid: 'HX1', + friendly_name: 'media_demo', + language: 'en', + status: 'approved', + template_type: 'media', + body: 'Hi {{1}}', + types: { 'twilio/media': { media: ['https://x.com/{{2}}'] } }, + ...overrides, +}); + +describe('#processVariable / #extractVariables', () => { + it('strips braces', () => { + expect(processVariable('{{contact.name}}')).toBe('contact.name'); + }); + it('returns ordered, de-duplicated variable keys', () => { expect( extractVariables('Hi {{name}}, again {{name}} and {{ order }}') ).toEqual(['name', 'order']); - }); - - it('returns an empty array for empty input', () => { expect(extractVariables('')).toEqual([]); }); }); -describe('#renderTemplatePreview / #renderTemplateLabel', () => { - it('fills provided values and keeps placeholders for missing ones', () => { +describe('#renderTemplatePreview', () => { + it('fills values and keeps placeholders for missing ones', () => { expect( renderTemplatePreview('Hi {{name}}, order {{id}}', { name: 'Sam' }) ).toBe('Hi Sam, order {{id}}'); }); - - it('renders labels with spaced braces', () => { - expect(renderTemplateLabel('Hi {{name}}')).toBe('Hi { name }'); - }); }); -describe('#buildPreviewSegments', () => { - it('marks filled and unfilled segments', () => { - expect(buildPreviewSegments('Hi {{name}}!', { name: 'Sam' })).toEqual([ - { text: 'Hi ', filled: false }, - { text: 'Sam', filled: true }, - { text: '!', filled: false }, - ]); +describe('#findComponentByType', () => { + it('finds a component by its type', () => { + const template = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(findComponentByType(template, 'HEADER')?.type).toBe('HEADER'); + expect(findComponentByType(template, 'BUTTONS')).toBeUndefined(); }); }); describe('#isSendableTemplate', () => { - it('accepts an approved, supported template', () => { + it('accepts an approved, supported template (case-insensitive)', () => { expect(isSendableTemplate(whatsAppTemplate())).toBe(true); - }); - - it('rejects non-approved templates (case-insensitive)', () => { - expect(isSendableTemplate(whatsAppTemplate({ status: 'PENDING' }))).toBe( - false - ); expect(isSendableTemplate(whatsAppTemplate({ status: 'APPROVED' }))).toBe( true ); }); - it('rejects authentication, csat, and unsupported components', () => { + it('rejects non-approved, authentication, csat and unsupported templates', () => { + expect(isSendableTemplate(whatsAppTemplate({ status: 'PENDING' }))).toBe( + false + ); expect( isSendableTemplate(whatsAppTemplate({ category: 'AUTHENTICATION' })) ).toBe(false); @@ -105,12 +113,45 @@ describe('#isSendableTemplate', () => { }); }); -describe('#normalizeWhatsApp', () => { - it('extracts body, header, actions and button params', () => { - const normalized = normalizeWhatsApp( +describe('#hasMediaHeader / #getMediaType / #isDocumentHeader', () => { + it('detects media headers and their type', () => { + const image = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(MEDIA_FORMATS).toContain('IMAGE'); + expect(hasMediaHeader(image)).toBe(true); + expect(getMediaType(image)).toBe('image'); + expect(isDocumentHeader(image)).toBe(false); + + const text = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'TEXT', text: 'Hello' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(hasMediaHeader(text)).toBe(false); + }); + + it('flags document headers', () => { + const doc = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'DOCUMENT' }, + { type: 'BODY', text: 'hi' }, + ], + }); + expect(isDocumentHeader(doc)).toBe(true); + }); +}); + +describe('#buildWhatsAppProcessedParams', () => { + it('builds the empty scaffold with body, media header and sparse buttons', () => { + const params = buildWhatsAppProcessedParams( whatsAppTemplate({ components: [ - { type: 'HEADER', format: 'IMAGE' }, + { type: 'HEADER', format: 'DOCUMENT' }, { type: 'BODY', text: 'Hi {{1}}' }, { type: 'BUTTONS', @@ -123,183 +164,100 @@ describe('#normalizeWhatsApp', () => { ], }) ); - expect(normalized.body).toBe('Hi {{1}}'); - expect(normalized.variables).toEqual(['1']); - expect(hasMediaHeader(normalized)).toBe(true); - expect(getMediaType(normalized)).toBe('image'); - expect(getHeaderSubtitle(normalized)).toBe('Image Header'); - expect(normalized.actions).toEqual(['Track', 'Copy', 'Yes']); - expect(normalized.buttons).toEqual([ - { index: 0, type: 'url', url: 'https://x.com/{{1}}', variables: ['1'] }, - { index: 1, type: 'copy_code' }, - ]); + expect(params).toEqual({ + body: { '1': '' }, + header: { media_url: '', media_type: 'document', media_name: '' }, + buttons: [ + { + type: 'url', + parameter: '', + url: 'https://x.com/{{1}}', + variables: ['1'], + }, + { type: 'copy_code', parameter: '' }, + ], + }); }); -}); -describe('#normalizeTwilio', () => { - const twilio: TwilioContentTemplate = { - contentSid: 'HX1', - friendlyName: 'media_demo', - language: 'en', - status: 'approved', - templateType: 'media', - body: 'Hi {{1}}', - types: { 'twilio/media': { media: ['https://x.com/{{2}}'] } }, - }; + it('returns an empty object when there is no body component', () => { + expect( + buildWhatsAppProcessedParams(whatsAppTemplate({ components: [] })) + ).toEqual({}); + }); +}); - it('returns null for non-approved templates (case-sensitive)', () => { - expect(normalizeTwilio({ ...twilio, status: 'Approved' })).toBeNull(); +describe('#isWhatsAppComplete', () => { + const template = whatsAppTemplate({ + components: [ + { type: 'HEADER', format: 'IMAGE' }, + { type: 'BODY', text: 'Hi {{1}}' }, + { type: 'BUTTONS', buttons: [{ type: 'COPY_CODE', text: 'Copy' }] }, + ], }); - it('extracts media variable key for media templates', () => { - const normalized = normalizeTwilio(twilio)!; - expect(normalized.isMediaTemplate).toBe(true); - expect(normalized.mediaVariableKey).toBe('2'); - expect(normalized.templateMediaUrl).toBe('https://x.com/{{2}}'); + it('requires body, media and button values', () => { + const params = buildWhatsAppProcessedParams(template); + expect(isWhatsAppComplete(template, params)).toBe(false); + params.body!['1'] = 'Sam'; + params.header!.media_url = 'https://x.com/a.png'; + params.buttons![0].parameter = 'CODE'; + expect(isWhatsAppComplete(template, params)).toBe(true); }); -}); -describe('#getTemplates / #filterTemplatesByQuery', () => { - it('combines sendable whatsapp and approved twilio templates', () => { - const templates = getTemplates( - [whatsAppTemplate(), whatsAppTemplate({ status: 'pending' })], - [ - { - contentSid: 'HX1', - friendlyName: 'twilio_one', - language: 'en', - status: 'approved', - body: 'hi', - }, - ] + it('is complete when there are no variables and no media header', () => { + const plain = whatsAppTemplate({ + components: [{ type: 'BODY', text: 'Thanks for reaching out.' }], + }); + expect(isWhatsAppComplete(plain, buildWhatsAppProcessedParams(plain))).toBe( + true ); - expect(templates.map(t => t.name)).toEqual(['order_update', 'twilio_one']); - expect(filterTemplatesByQuery(templates, 'twilio')).toHaveLength(1); - expect(filterTemplatesByQuery(templates, '')).toHaveLength(2); }); }); -describe('#isTemplateComplete', () => { - it('requires body, media and button inputs for whatsapp', () => { - const template: NormalizedTemplate = { - id: 'a', - name: 'a', - platform: 'whatsapp', - language: 'en', - body: 'Hi {{1}}', - variables: ['1'], - header: { format: 'IMAGE' }, - buttons: [{ index: 0, type: 'copy_code' }], - }; - const state = createEmptyFormState(); - expect(isTemplateComplete(template, state)).toBe(false); - state.bodyValues['1'] = 'Sam'; - state.mediaUrl = 'https://x.com/a.png'; - state.buttonValues[0] = 'CODE'; - expect(isTemplateComplete(template, state)).toBe(true); +describe('twilio helpers', () => { + it('identifies media templates and their media variable key', () => { + const template = twilioTemplate(); + expect(isTwilioMediaTemplate(template)).toBe(true); + expect(getTwilioMediaUrl(template)).toBe('https://x.com/{{2}}'); + expect(getTwilioMediaVariableKey(template)).toBe('2'); + expect( + getTwilioMediaVariableKey(twilioTemplate({ template_type: 'text' })) + ).toBeNull(); }); - it('is complete when twilio has no variables and no media', () => { - const template: NormalizedTemplate = { - id: 'b', - name: 'b', - platform: 'twilio', - language: 'en', - body: 'hello', - variables: [], - }; - expect(isTemplateComplete(template, createEmptyFormState())).toBe(true); + it('builds the empty processed_params for body + media variables', () => { + expect(buildTwilioProcessedParams(twilioTemplate())).toEqual({ + '1': '', + '2': '', + }); }); -}); -describe('#buildTemplateParams / #buildTemplateSendPayload', () => { - it('builds nested whatsapp processed params with sparse buttons', () => { - const template: NormalizedTemplate = { - id: 'a', - name: 'order_update', - platform: 'whatsapp', - language: 'en', - category: 'MARKETING', - namespace: 'ns', - body: 'Hi {{1}}', - variables: ['1'], - header: { format: 'DOCUMENT' }, - buttons: [ - { index: 1, type: 'url', url: 'https://x.com/{{1}}', variables: ['1'] }, - ], - }; - const state = createEmptyFormState(); - state.bodyValues['1'] = 'Sam'; - state.mediaUrl = 'https://x.com/invoice.pdf'; - state.mediaName = 'invoice.pdf'; - state.buttonValues[1] = 'TRACK'; + it('validates completeness', () => { + const template = twilioTemplate(); + const params = buildTwilioProcessedParams(template); + expect(isTwilioComplete(template, params)).toBe(false); + params['1'] = 'Sam'; + params['2'] = 'https://x.com/photo.png'; + expect(isTwilioComplete(template, params)).toBe(true); + }); - const params = buildTemplateParams(template, state); - expect(params).toEqual({ - name: 'order_update', - category: 'MARKETING', - language: 'en', - namespace: 'ns', - processed_params: { - body: { '1': 'Sam' }, - header: { - media_url: 'https://x.com/invoice.pdf', - media_type: 'document', - media_name: 'invoice.pdf', - }, - buttons: [ - undefined, - { - type: 'url', - parameter: 'TRACK', - url: 'https://x.com/{{1}}', - variables: ['1'], - }, - ], - }, + it('is complete when there are no variables and no media', () => { + const plain = twilioTemplate({ + template_type: 'text', + body: 'hello', + types: {}, }); - expect(isDocumentHeader(template)).toBe(true); + expect(isTwilioComplete(plain, buildTwilioProcessedParams(plain))).toBe( + true + ); }); - it('builds flat twilio params and resolves media filename', () => { - const template: NormalizedTemplate = { - id: 'b', - name: 'media_demo', - platform: 'twilio', - language: 'en', - body: 'Hi {{1}}', - variables: ['1'], - isMediaTemplate: true, - mediaVariableKey: '2', - }; - const state = createEmptyFormState(); - state.bodyValues['1'] = 'Sam'; - state.mediaUrl = 'https://x.com/path/photo.png?token=1'; - - const payload = buildTemplateSendPayload(template, state); - expect(payload.message).toBe('Hi Sam'); - expect(payload.templateParams.processed_params).toEqual({ + it('reduces the media variable value to a filename on send', () => { + const template = twilioTemplate(); + const params = { '1': 'Sam', '2': 'https://x.com/path/photo.png?token=1' }; + expect(applyTwilioMediaFilename(template, params)).toEqual({ '1': 'Sam', '2': 'photo.png', }); }); }); - -describe('#renderTemplateMessage', () => { - it('injects twilio media url into the rendered message', () => { - const template: NormalizedTemplate = { - id: 'b', - name: 'b', - platform: 'twilio', - language: 'en', - body: 'See {{2}}', - variables: [], - mediaVariableKey: '2', - }; - const state = createEmptyFormState(); - state.mediaUrl = 'https://x.com/a.png'; - expect(renderTemplateMessage(template, state)).toBe( - 'See https://x.com/a.png' - ); - }); -});