@codebucket/whatsapp
Version:
A reusable WhatsApp Business API client with template and non-template support, webhook handling, pluggable storage, and text sanitization
240 lines (218 loc) • 9.9 kB
text/typescript
import axios from 'axios';
import { ComponentDef, TemplateDef, TemplateVariables, Mode } from './types';
import { cleanTemplateText, validateTemplateText } from './utils';
const templateCache = new Map<string, TemplateDef>();
async function fetchTemplate(
businessAccountId: string,
accessToken: string,
templateName: string,
language = 'en'
): Promise<TemplateDef> {
const key = `${businessAccountId}::${templateName}::${language}`;
if (templateCache.has(key)) return templateCache.get(key)!;
const resp = await axios.get(
`https://graph.facebook.com/v22.0/${businessAccountId}/message_templates`,
{ params: {status: 'APPROVED', name: templateName, language },
headers: {Authorization: `Bearer ${accessToken}`,} },
);
const data = resp.data.data as any[];
if (!data.length) throw new Error(`Template '${templateName}' not found.`);
const tpl = data[0];
const def: TemplateDef = { name: tpl.name, language: tpl.language, components: tpl.components as ComponentDef[] };
templateCache.set(key, def);
return def;
}
function detectMode(keys: string[]): Mode {
if (!keys.length) return Mode.NUMERIC;
return keys.every(k => /^\d+$/.test(k)) ? Mode.NUMERIC : Mode.NAMED;
}
function isStructured(
vars: TemplateVariables
): vars is { header?: string; body?: Record<string,string>; buttons?: string[] } {
return (
!Array.isArray(vars) &&
(vars.header !== undefined ||
vars.body !== undefined ||
vars.buttons!== undefined)
)
}
export function extractKeys(components: ComponentDef[]): string[] {
const bodyText = components.find(c=>c.type==='BODY'&&c.text)?.text||''
const regex = /\{\{(\w+)\}\}/g
const keys: string[] = []
let m: RegExpExecArray|null
while ((m = regex.exec(bodyText)) !== null) {
if (!keys.includes(m[1])) keys.push(m[1])
}
return keys
}
export function normalizeVariables(
vars: TemplateVariables,
mode: Mode,
keys: string[]
): { variables: TemplateVariables; warnings: string[] } {
const warnings: string[] = []
// 1) FLAT ARRAY INPUT
if (Array.isArray(vars)) {
// find how many body placeholders we expect
const bodyCount = keys.length
// detect if first item is a media header (we'd need access to components for that
// but simplest heuristic: if mode===NUMERIC and vars.length > bodyCount+? then assume header)
const hasHeader = mode === Mode.NUMERIC && vars.length > bodyCount + // buttons?
bodyCount
const headerSliceLen = hasHeader ? 1 : 0
// slice out the body segment
const bodyRaw = vars.slice(headerSliceLen, headerSliceLen + bodyCount)
const bodyClean: string[] = bodyRaw.map((raw, i) => {
const vs = validateTemplateText(raw)
vs.forEach(w => warnings.push(`Placeholder #${i+1}: ${w}`))
return cleanTemplateText(raw)
})
// rebuild the flat array: [ …headerRaw, …bodyClean, …buttonsRaw ]
return {
variables: [
...vars.slice(0, headerSliceLen), // raw header
...bodyClean, // cleaned body
...vars.slice(headerSliceLen + bodyCount) // raw buttons
],
warnings
}
}
// 2) STRUCTURED INPUT { header?, body?, buttons? }
// – header is untouched
// – buttons are untouched
// – only clean/validate body[key]
const struct = vars as { header?:string; body?:Record<string,string>; buttons?:string[] }
const cleanBody: Record<string,string> = {}
keys.forEach(key => {
const raw = struct.body?.[key] ?? ''
const vs = validateTemplateText(raw)
vs.forEach(w => warnings.push(`Variable "${key}": ${w}`))
cleanBody[key] = cleanTemplateText(raw)
})
return {
variables: {
header: struct.header, // raw, no cleaning
body: cleanBody, // cleaned named placeholders
buttons: struct.buttons // raw, no cleaning
},
warnings
}
}
export function buildComponents(
components: ComponentDef[],
variables: TemplateVariables,
mode: Mode,
keys: string[]
): any[] {
// ── 1) Figure out your header value ───────────────────────────────────────────
const headerVal = isStructured(variables) && variables.header !== undefined
? variables.header
: Array.isArray(variables)
? variables[0]
: undefined
// ── 2) Positional flat array (for numeric & fallback) ────────────────────────
const positionalArr: string[] = Array.isArray(variables)
? variables
: mode === Mode.NUMERIC
? Object.keys(variables)
.filter(k => /^\d+$/.test(k))
.sort((a,b) => +a - +b)
.map(k => (variables as Record<string,string>)[k] ?? '')
: []
// ── 3) Body values ───────────────────────────────────────────────────────────
const bodyVals: string[] = isStructured(variables) && variables.body
? // structured named-body
keys.map(k => variables.body![k] ?? '')
: mode === Mode.NAMED
? // raw record→array
keys.map(k => (variables as Record<string,string>)[k] ?? '')
: // positional slice after header
positionalArr.slice(headerVal != null ? 1 : 0, (headerVal != null ? 1 : 0) + keys.length)
// ── 4) Button values ─────────────────────────────────────────────────────────
const buttonVals: string[] = isStructured(variables) && Array.isArray(variables.buttons)
? variables.buttons!
: positionalArr.slice((headerVal != null ? 1 : 0) + bodyVals.length)
// ── 5) Build each component ─────────────────────────────────────────────────
return components.map(comp => {
switch (comp.type) {
case 'HEADER':
// — Media header? —
if (comp.format && comp.format !== 'TEXT' && headerVal) {
const mtype = comp.format.toLowerCase()
const param: any = { type: mtype }
if (/^https?:\/\//.test(headerVal)) {
param[mtype] = { link: headerVal }
} else {
param[mtype] = { id: headerVal }
}
return { type: 'header', parameters: [param] }
}
// — Text header placeholders —
if (comp.text?.includes('{{')) {
if (mode === Mode.NAMED) {
return {
type: 'header',
parameters: keys.map(name => ({
type: 'text',
parameter_name: name,
text: (variables as any).body?.[name] ?? ''
}))
}
}
return {
type: 'header',
parameters: (Array.isArray(variables) ? positionalArr : bodyVals)
.map(t => ({ type: 'text', text: t }))
}
}
// — Static header —
return { type: 'header' }
case 'BODY':
if (mode === Mode.NAMED) {
return {
type: 'body',
parameters: keys.map(name => ({
type: 'text',
parameter_name: name,
text: (variables as any).body?.[name] ?? ''
}))
}
}
return {
type: 'body',
parameters: bodyVals.map(t => ({ type: 'text', text: t }))
}
case 'BUTTON':
const idx = comp.index ?? 0
const text = buttonVals[idx] ?? ''
return {
type: 'button',
sub_type: comp.sub_type?.toLowerCase(),
index: idx,
parameters:[{ type:'text', text }]
}
default:
return { type: comp.type.toLowerCase() }
}
})
}
/**
* Creates a template payload and returns payload plus validation warnings.
*/
export async function createWhatsAppTemplatePayload(opts: {
businessAccountId: string,
accessToken: string,
to: string,
templateName: string,
language?: string,
variables: TemplateVariables
}): Promise<{ payload: any, warnings: string[] }> {
const tpl = await fetchTemplate(opts.businessAccountId, opts.accessToken, opts.templateName, opts.language);
const keys = extractKeys(tpl.components);
const mode = detectMode(keys);
const { variables, warnings } = normalizeVariables(opts.variables, mode, keys);
const comps = buildComponents(tpl.components, variables, mode,keys);
const payload = { recipient_type:'individual', to:opts.to, type:'template', template:{name:tpl.name,language:{code:tpl.language},components:comps} };
return { payload, warnings };
}