@codebucket/whatsapp
Version:
A reusable WhatsApp Business API client with template and non-template support, webhook handling, pluggable storage, and text sanitization
205 lines (204 loc) • 9.43 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractKeys = extractKeys;
exports.normalizeVariables = normalizeVariables;
exports.buildComponents = buildComponents;
exports.createWhatsAppTemplatePayload = createWhatsAppTemplatePayload;
const axios_1 = __importDefault(require("axios"));
const types_1 = require("./types");
const utils_1 = require("./utils");
const templateCache = new Map();
async function fetchTemplate(businessAccountId, accessToken, templateName, language = 'en') {
const key = `${businessAccountId}::${templateName}::${language}`;
if (templateCache.has(key))
return templateCache.get(key);
const resp = await axios_1.default.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;
if (!data.length)
throw new Error(`Template '${templateName}' not found.`);
const tpl = data[0];
const def = { name: tpl.name, language: tpl.language, components: tpl.components };
templateCache.set(key, def);
return def;
}
function detectMode(keys) {
if (!keys.length)
return types_1.Mode.NUMERIC;
return keys.every(k => /^\d+$/.test(k)) ? types_1.Mode.NUMERIC : types_1.Mode.NAMED;
}
function isStructured(vars) {
return (!Array.isArray(vars) &&
(vars.header !== undefined ||
vars.body !== undefined ||
vars.buttons !== undefined));
}
function extractKeys(components) {
const bodyText = components.find(c => c.type === 'BODY' && c.text)?.text || '';
const regex = /\{\{(\w+)\}\}/g;
const keys = [];
let m;
while ((m = regex.exec(bodyText)) !== null) {
if (!keys.includes(m[1]))
keys.push(m[1]);
}
return keys;
}
function normalizeVariables(vars, mode, keys) {
const warnings = [];
// 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 === types_1.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 = bodyRaw.map((raw, i) => {
const vs = (0, utils_1.validateTemplateText)(raw);
vs.forEach(w => warnings.push(`Placeholder #${i + 1}: ${w}`));
return (0, utils_1.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;
const cleanBody = {};
keys.forEach(key => {
const raw = struct.body?.[key] ?? '';
const vs = (0, utils_1.validateTemplateText)(raw);
vs.forEach(w => warnings.push(`Variable "${key}": ${w}`));
cleanBody[key] = (0, utils_1.cleanTemplateText)(raw);
});
return {
variables: {
header: struct.header, // raw, no cleaning
body: cleanBody, // cleaned named placeholders
buttons: struct.buttons // raw, no cleaning
},
warnings
};
}
function buildComponents(components, variables, mode, keys) {
// ── 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 = Array.isArray(variables)
? variables
: mode === types_1.Mode.NUMERIC
? Object.keys(variables)
.filter(k => /^\d+$/.test(k))
.sort((a, b) => +a - +b)
.map(k => variables[k] ?? '')
: [];
// ── 3) Body values ───────────────────────────────────────────────────────────
const bodyVals = isStructured(variables) && variables.body
? // structured named-body
keys.map(k => variables.body[k] ?? '')
: mode === types_1.Mode.NAMED
? // raw record→array
keys.map(k => variables[k] ?? '')
: // positional slice after header
positionalArr.slice(headerVal != null ? 1 : 0, (headerVal != null ? 1 : 0) + keys.length);
// ── 4) Button values ─────────────────────────────────────────────────────────
const buttonVals = 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 = { 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 === types_1.Mode.NAMED) {
return {
type: 'header',
parameters: keys.map(name => ({
type: 'text',
parameter_name: name,
text: variables.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 === types_1.Mode.NAMED) {
return {
type: 'body',
parameters: keys.map(name => ({
type: 'text',
parameter_name: name,
text: variables.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.
*/
async function createWhatsAppTemplatePayload(opts) {
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 };
}