spike(canais): deriveTemplateContract contra os 5 templates reais da WABA

Fixture com o payload REAL da Graph API (sem credencial). O spike achou
duas coisas que a contagem ingenua de {{n}} nao ve:

1. CAROUSEL aninha — cada card tem header/body/buttons proprios, com
   indices proprios no payload. O ParamSlot plano que eu tinha desenhado
   nao expressa isso; o endereco virou recursivo.
2. Header de midia (format IMAGE, sem text e sem example) nao tem {{n}}
   nenhum e mesmo assim e um slot. Contando placeholder daria 0 params
   para 2 dos 5 templates.

NAO provado end-to-end: a Graph API valida destinatario ANTES do payload
(131030), entao nao consegui fazer a API confirmar que header de midia e
obrigatorio. Evidencia e estrutural (format sem conteudo), nao empirica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3
This commit is contained in:
Rafael Melgaço
2026-07-28 10:24:33 -03:00
co-authored by Claude Opus 5
parent f6b175542b
commit 93a3a91d34
3 changed files with 411 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
/**
* Derivação do CONTRATO DE PARÂMETROS de um template da Meta (spike da Fase 3a).
*
* A razão de existir, em uma frase: "number of parameters does not match" acontece
* porque a definição do template vive na Meta e alguém REDIGITA quantos parâmetros ela
* tem do lado de cá. Duas declarações do mesmo fato divergem — é só questão de tempo.
* Aqui o contrato é **derivado**, nunca redigitado (doutrina `restricao-de-canal.md`,
* seção "Contrato de parâmetros").
*
* Esta função é PURA e tem DOIS consumidores, e é isso que fecha o buraco:
* 1. o formulário da tela — um campo por slot, rotulado pelo texto ao redor;
* 2. o montador do payload de envio — os mesmos slots viram `components[]`.
* Mesma derivação nos dois lados ⇒ divergir vira impossível por construção, não por
* disciplina.
*
* O endereço do slot é RECURSIVO porque carrossel aninha: o payload de envio tem
* `{type:'carousel', cards:[{card_index, components:[...]}]}`, e cada card carrega
* seus próprios header/body/buttons com índices próprios. Uma lista plana não
* expressa isso — e contrato que não expressa gera formulário incompleto, que é
* exatamente o defeito original com outra roupa.
*/
export type ButtonSubType = "url" | "copy_code" | "quick_reply";
/** Tipo de valor que o slot espera — decide o widget na tela e o `type` no payload. */
export type SlotExpects =
| "text"
| "currency"
| "date_time"
| "image"
| "video"
| "document"
| "url_suffix"
| "coupon_code";
/** Endereço do slot dentro de `components[]`. `card` é o único ramo que aninha. */
export type SlotAddress =
| { kind: "header" }
| { kind: "body" }
| { kind: "button"; subType: ButtonSubType; index: number }
| { kind: "card"; cardIndex: number; inner: LeafAddress };
export type LeafAddress = Exclude<SlotAddress, { kind: "card" }>;
export interface ParamSlot {
address: SlotAddress;
/** '1'|'2' (posicional) ou 'customer_name' (nomeado) — a Meta aceita os dois. */
key: string;
expects: SlotExpects;
/** Texto imediatamente antes/depois do placeholder — é o rótulo do campo na tela.
* Sem isto o formulário mostraria "Parâmetro 1", que não ajuda ninguém a preencher.
* Vazio quando o slot é de mídia (não há texto ao redor). */
contextBefore: string;
contextAfter: string;
}
export interface TemplateContract {
name: string;
language: string;
slots: ParamSlot[];
}
interface MetaButton {
type?: string;
text?: string;
url?: string;
}
interface MetaComponent {
type?: string;
format?: string;
text?: string;
buttons?: MetaButton[];
cards?: { components?: MetaComponent[] }[];
}
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
const CONTEXT_CHARS = 40;
/** Extrai os placeholders de um texto, com o contexto que vira rótulo na tela. */
function slotsFromText(text: string, address: LeafAddress, expects: SlotExpects): ParamSlot[] {
const out: ParamSlot[] = [];
for (const m of text.matchAll(PLACEHOLDER)) {
const at = m.index ?? 0;
out.push({
address,
key: m[1]!,
expects,
contextBefore: text.slice(Math.max(0, at - CONTEXT_CHARS), at).replace(/\s+/g, " ").trimStart(),
contextAfter: text
.slice(at + m[0].length, at + m[0].length + CONTEXT_CHARS)
.replace(/\s+/g, " ")
.trimEnd(),
});
}
return out;
}
const MEDIA_FORMAT: Record<string, SlotExpects> = {
IMAGE: "image",
VIDEO: "video",
DOCUMENT: "document",
};
function buttonSubType(b: MetaButton): ButtonSubType | null {
const t = (b.type ?? "").toUpperCase();
if (t === "URL") return "url";
if (t === "COPY_CODE") return "copy_code";
return null; // QUICK_REPLY e PHONE_NUMBER não levam parâmetro
}
/** Slots de UM nível (header/body/buttons) — reusado dentro e fora de card. */
function slotsFromLeafComponents(
components: MetaComponent[],
wrap: (leaf: LeafAddress) => SlotAddress,
): ParamSlot[] {
const out: ParamSlot[] = [];
for (const c of components) {
const type = (c.type ?? "").toUpperCase();
if (type === "HEADER") {
const media = MEDIA_FORMAT[(c.format ?? "").toUpperCase()];
if (media) {
// Header de mídia é UM slot inteiro, sem {{n}} no texto — quem esquece
// disto monta `components[]` sem o header e leva erro da Meta.
out.push({
address: wrap({ kind: "header" }),
key: "1",
expects: media,
contextBefore: "",
contextAfter: "",
});
} else if (c.text) {
out.push(...slotsFromText(c.text, { kind: "header" }, "text").map((s) => ({ ...s, address: wrap(s.address as LeafAddress) })));
}
continue;
}
if (type === "BODY" && c.text) {
out.push(...slotsFromText(c.text, { kind: "body" }, "text").map((s) => ({ ...s, address: wrap(s.address as LeafAddress) })));
continue;
}
if (type === "BUTTONS") {
(c.buttons ?? []).forEach((b, index) => {
const subType = buttonSubType(b);
if (!subType) return;
if (subType === "copy_code") {
out.push({
address: wrap({ kind: "button", subType, index }),
key: "1",
expects: "coupon_code",
contextBefore: b.text ?? "",
contextAfter: "",
});
return;
}
// Botão URL só leva parâmetro se a url tem placeholder (sufixo dinâmico).
for (const s of slotsFromText(b.url ?? "", { kind: "button", subType, index }, "url_suffix")) {
out.push({ ...s, address: wrap(s.address as LeafAddress) });
}
});
}
// FOOTER nunca aceita parâmetro.
}
return out;
}
export function deriveTemplateContract(t: {
name: string;
language: string;
components?: MetaComponent[];
}): TemplateContract {
const components = t.components ?? [];
const slots: ParamSlot[] = slotsFromLeafComponents(components, (leaf) => leaf);
// Carrossel: cada card repete a estrutura folha, com card_index próprio.
for (const c of components) {
if ((c.type ?? "").toUpperCase() !== "CAROUSEL") continue;
(c.cards ?? []).forEach((card, cardIndex) => {
slots.push(
...slotsFromLeafComponents(card.components ?? [], (leaf) => ({
kind: "card",
cardIndex,
inner: leaf,
})),
);
});
}
return { name: t.name, language: t.language, slots };
}
/** Rótulo humano de um endereço — usado na tela e nas mensagens de erro. */
export function describeAddress(a: SlotAddress): string {
switch (a.kind) {
case "header":
return "cabeçalho";
case "body":
return "corpo";
case "button":
return `botão ${a.index + 1} (${a.subType})`;
case "card":
return `card ${a.cardIndex + 1} › ${describeAddress(a.inner)}`;
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Spike da Fase 3a: roda `deriveTemplateContract` contra os 5 templates REAIS da
* WABA de teste e imprime o contrato de cada um. Serve para eyeball do desenho
* antes de escrever o plano — não é teste, é instrumento de decisão.
*
* pnpm exec tsx scripts/spike-template-contract.ts
*/
import { readFileSync } from "node:fs";
import { deriveTemplateContract, describeAddress } from "@/lib/channels/meta/template-contract";
const fixture = JSON.parse(
readFileSync("tests/fixtures/meta/message-templates.json", "utf8"),
) as { data: { name: string; language: string; components?: unknown[] }[] };
for (const t of fixture.data) {
const contract = deriveTemplateContract(t as Parameters<typeof deriveTemplateContract>[0]);
console.log(`\n━━ ${contract.name} (${contract.language}) — ${contract.slots.length} slot(s)`);
if (contract.slots.length === 0) {
console.log(" (sem parâmetros — envio não precisa de components[])");
continue;
}
for (const s of contract.slots) {
const campo =
s.contextBefore || s.contextAfter
? `${s.contextBefore}[ ${s.key} ]${s.contextAfter}`
: `[ ${s.key} ]`;
console.log(` ${describeAddress(s.address).padEnd(26)} ${s.expects.padEnd(12)} ${campo}`);
}
}
console.log();
+174
View File
@@ -0,0 +1,174 @@
{
"data": [
{
"name": "hello_world",
"language": "en_US",
"status": "APPROVED",
"category": "UTILITY",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Hello World"
},
{
"type": "BODY",
"text": "Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us."
},
{
"type": "FOOTER",
"text": "WhatsApp Business Platform sample message"
}
],
"id": "1715954029565688"
},
{
"name": "jaspers_market_media_carousel_v1",
"language": "en_US",
"status": "APPROVED",
"category": "MARKETING",
"components": [
{
"type": "BODY",
"text": "Our in-house chefs have prepared some delicious and fresh summer recipes."
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Get free delivery",
"url": "https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/utility-templates"
}
]
},
{
"type": "CAROUSEL",
"cards": [
{
"components": [
{
"type": "HEADER",
"format": "IMAGE"
},
{
"type": "BODY",
"text": "Simple and Healthy Sheet Pan Dinner to Feed the Whole Family"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Get this recipe",
"url": "https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/media-card-carousel-templates"
}
]
}
]
},
{
"components": [
{
"type": "HEADER",
"format": "IMAGE"
},
{
"type": "BODY",
"text": "3 Plant-Powered Salad Bowls to Fuel Your Week"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Get this recipe",
"url": "https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/media-card-carousel-templates"
}
]
}
]
}
]
}
],
"id": "1632430928029785"
},
{
"name": "jaspers_market_image_cta_v1",
"language": "en_US",
"status": "APPROVED",
"category": "MARKETING",
"components": [
{
"type": "HEADER",
"format": "IMAGE"
},
{
"type": "BODY",
"text": "Free delivery for all online orders with Jasper's Market"
},
{
"type": "FOOTER",
"text": "developers.facebook.com"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Get free delivery",
"url": "https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/utility-templates"
}
]
}
],
"id": "1616979502874643"
},
{
"name": "jaspers_market_order_confirmation_v1",
"language": "en_US",
"status": "APPROVED",
"category": "UTILITY",
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "Order confirmed"
},
{
"type": "BODY",
"text": "Hi {{1}},\n\nThank you for your purchase! Your order number is {{2}}.\n\nWe'll start getting your farm fresh groceries ready to ship.\n\nEstimated delivery: {{3}}.\n\nWe will let you know when your order ships."
},
{
"type": "FOOTER",
"text": "developers.facebook.com"
},
{
"type": "BUTTONS",
"buttons": [
{
"type": "URL",
"text": "Visit order details",
"url": "https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/utility-templates"
}
]
}
],
"id": "1012602094422872"
},
{
"name": "jaspers_market_plain_text_v1",
"language": "en_US",
"status": "APPROVED",
"category": "MARKETING",
"components": [
{
"type": "BODY",
"text": "Welcome to Jasper’s Market, your local grocery store providing farm-fresh produce and high-quality goods!"
}
],
"id": "999668695790367"
}
]
}