mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
f6b175542b
commit
93a3a91d34
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user