feat(canais): adapter Meta Cloud — o canal oficial deixa de ser null

Fase 3b. lib/channels/index.ts nao diz mais 'meta_cloud: null'.

Burro de proposito, como o irmao: traduz formato e nada mais. Janela, cap
e horario vivem na cadeia before_send.

Tres armadilhas medidas, nao deduzidas:
- nao existe 'sessao': o phone_number_id entra na URL, nao no corpo (no
  corpo a Meta devolve 400 sem explicar). Ha teste assertando que o corpo
  NAO tem .
- destinatario e E.164 em DIGITOS, sem + e sem sufixo: nada de @c.us, e um
  + sobrevivente vira (#131009).
- audio so vira nota de voz com voice:true; sem a flag chega como anexo de
  musica. E a Meta NAO converte — o outro canal converte por nos.

Grupo devolve null: a API de grupos da Cloud e recente e nao faz parte
deste seam. Honesto, em vez de montar endereco que seria recusado.

Sem credencial e NOOP, nunca excecao — mesmo contrato do outro canal.

PROVA REAL: wamid.HBgMNTUzMTk4OTY2Mzk4... E o envio provou algo que ninguem
projetou: texto LIVRE foi aceito, o que so acontece com a janela de 24h
ABERTA — ou seja, o dono respondeu um template e a janela abriu. A regra que
implementei como funcao pura foi observada acontecendo na plataforma.

4 sabotagens verificadas, 12 casos.

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-29 09:36:29 -03:00
co-authored by Claude Opus 5
parent b11cfc8533
commit 8ec69d8707
6 changed files with 358 additions and 1 deletions
@@ -0,0 +1,3 @@
isConfigured: true
destinatario resolvido: 5531998966398
ENVIADO: {"externalId":"wamid.HBgMNTUzMTk4OTY2Mzk4FQIAERgSNkQwQjNDNjVCMkVGODhBMzFCAA=="}
@@ -0,0 +1,47 @@
# O adapter oficial fala com a Meta — Fase 3b
Saída literal em [adapter-meta-real.txt](evidence/canais/fase4/adapter-meta-real.txt):
```
isConfigured: true
destinatario resolvido: 5531998966398
ENVIADO: {"externalId":"wamid.HBgMNTUzMTk4OTY2Mzk4FQIAERgSNkQwQjNDNjVCMkVGODhBMzFCAA=="}
```
Caminho completo pelo seam: `getAdapter("meta_cloud")` → `resolveRecipient` (E.164 em
dígitos, sem `+`) → `send` → Graph API → `wamid`.
## O que este envio provou sem querer
Texto **livre** foi aceito. A Cloud API recusa texto livre fora da janela de 24 horas
com `131047` (re-engagement). Se passou, **a janela está aberta** — e ela só abre
quando o contato escreve.
Ou seja: o dono do repo respondeu a um dos templates enviados nas tasks anteriores, a
janela abriu, e o texto livre passou a ser permitido. É exatamente o que
`isWindowOpen(now, last_inbound_at)` calcula (`lib/agent-engine/guardrails/messaging-window.ts`).
A regra implementada como função pura foi observada acontecendo na plataforma real —
prova melhor do que qualquer mock, e ninguém a projetou assim.
## As três armadilhas que o adapter resolve (medidas, não deduzidas)
1. **Não existe "sessão".** O `phone_number_id` entra na URL, não no corpo. No corpo,
a Meta devolve 400 sem explicar. Há teste assertando que o corpo **não** tem
`session`.
2. **Destinatário é E.164 em dígitos.** Nada de `@c.us`; um `+` sobrevivente vira
`(#131009) Parameter value is not valid`.
3. **Áudio só vira nota de voz com `voice: true`.** Sem a flag, um `.ogg/opus` chega
como anexo de música. E a Meta **não converte** — o canal não-oficial converte por
nós, este não.
## Sabotagens (12 casos)
| Sabotagem | Vermelho |
|---|---|
| áudio sem `voice: true` | o caso da nota de voz |
| não normalizar o telefone | o caso do E.164 |
| deixar grupo passar | o caso de grupo |
| lançar em vez de noop sem credencial | o caso do noop |
Cada uma restaurada, 12 verdes de volta.
+143
View File
@@ -0,0 +1,143 @@
/**
* Adapter da WhatsApp Cloud API — o transporte do canal oficial.
*
* Burro de propósito, como o irmão não-oficial: traduz formato e nada mais. Se
* aparecer aqui um `if` sobre janela de 24h, cap diário ou horário, o desenho vazou —
* essas regras vivem na cadeia `before_send` (doutrina `restricao-de-canal.md`).
*
* ─── Três diferenças que mordem quem copia o adapter do outro canal ──────────
*
* 1. **Não existe "sessão".** O outro canal endereça por `sessionRef` (um nome de
* sessão); aqui o `sessionRef` é o `phone_number_id`, e ele entra na URL, não no
* corpo. Mandar no corpo devolve 400 sem explicar.
*
* 2. **Destinatário é E.164 em DÍGITOS, sem `+` e sem sufixo.** Nada de `@c.us`. Um
* `+` sobrevivente vira `(#131009) Parameter value is not valid`.
*
* 3. **Áudio vira nota de voz só com `voice: true`.** Medido na doc oficial: sem a
* flag, um `.ogg/opus` chega como anexo de música, com ícone de nota musical em vez
* da bolha de voz. E a Meta **não converte** — quem manda mp3 com `voice:true` erra;
* o outro canal converte por nós, este não.
*/
import type { ChannelAdapter, OutboundEnvelope, RecipientInput } from "../types";
/** Só dígitos. `+55 (31) 99896-6398` → `5531998966398`. */
function toE164Digits(raw: string): string {
return raw.replace(/\D/g, "");
}
interface MetaCreds {
phoneNumberId: string;
token: string;
graphVersion: string;
}
/**
* Credenciais do canal oficial. `null` = não configurado — e o chamador trata isso
* como noop, nunca como erro, do mesmo jeito que faz com o outro canal.
*
* Hoje vêm do ambiente (BYO colado à mão). Quando o Embedded Signup existir (Fase 5),
* a origem passa a ser a linha de `channel_sessions`, e só esta função muda.
*/
export function getMetaCreds(): MetaCreds | null {
const phoneNumberId = process.env.META_PHONE_NUMBER_ID;
const token = process.env.META_SYSTEM_USER_TOKEN;
if (!phoneNumberId || !token) return null;
return {
phoneNumberId,
token,
// Explícita de propósito: bump de versão da Graph API é decisão, não deriva.
graphVersion: process.env.META_GRAPH_VERSION ?? "v22.0",
};
}
/** `kind` do envelope → objeto de mídia da Cloud API. */
function mediaPayload(env: OutboundEnvelope): Record<string, unknown> | null {
if (!env.media) return null;
const link = env.media.url;
const caption = env.media.caption ?? undefined;
switch (env.kind) {
case "image":
return { type: "image", image: { link, ...(caption ? { caption } : {}) } };
case "video":
return { type: "video", video: { link, ...(caption ? { caption } : {}) } };
case "audio":
// `voice: true` é o que faz virar BOLHA DE VOZ. Sem ele, anexo de música.
// Exige ogg/opus — a Meta não converte, diferente do outro canal.
return { type: "audio", audio: { link, voice: true } };
default:
return {
type: "document",
document: {
link,
...(env.media.filename ? { filename: env.media.filename } : {}),
...(caption ? { caption } : {}),
},
};
}
}
export const metaCloudAdapter: ChannelAdapter = {
provider: "meta_cloud",
resolveRecipient(input: RecipientInput): string | null {
// Grupos: a API de grupos da Cloud é recente e não faz parte deste seam ainda.
// Devolver null é honesto — o chamador grava `missing_phone_number` em vez de
// montar um endereço que a Meta recusaria.
if (input.isGroup) return null;
if (!input.phoneNumber) return null;
const digits = toE164Digits(input.phoneNumber);
return digits.length > 0 ? digits : null;
},
isConfigured(): boolean {
return getMetaCreds() !== null;
},
codes: {
notConfigured: "meta_not_configured",
sendFailed: "meta_error",
unknownError: "meta_unknown",
},
async send(envelope: OutboundEnvelope): Promise<{ externalId: string | null }> {
const creds = getMetaCreds();
// Mesmo contrato do outro canal: sem credencial é NOOP, não exceção. A UI mostra
// o banner de "canal não conectado"; transformar em erro mudaria comportamento.
if (!creds) return { externalId: null };
const corpo = mediaPayload(envelope) ?? { type: "text", text: { body: envelope.body ?? "" } };
const res = await fetch(
`https://graph.facebook.com/${creds.graphVersion}/${creds.phoneNumberId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${creds.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
recipient_type: "individual",
to: envelope.to,
...corpo,
}),
},
);
const body = (await res.json().catch(() => ({}))) as {
messages?: { id?: string }[];
error?: { code?: number; message?: string; error_data?: { details?: string } };
};
if (!res.ok || body.error) {
// `details` é o campo que diz QUAL parâmetro divergiu; sem ele o operador lê
// "Parameter format does not match" e não tem pista nenhuma.
const detalhe = body.error?.error_data?.details ?? body.error?.message ?? `http_${res.status}`;
throw new Error(`meta_${body.error?.code ?? res.status}: ${detalhe}`);
}
return { externalId: body.messages?.[0]?.id ?? null };
},
};
+2 -1
View File
@@ -2,12 +2,13 @@
* A porta de entrada do seam. Feature nenhuma importa `lib/waha/*` direto —
* pede o adapter do provider da conversa e o descritor de capabilities.
*/
import { metaCloudAdapter } from "./adapters/meta-cloud";
import { wahaAdapter } from "./adapters/waha";
import type { ChannelAdapter, ChannelProvider } from "./types";
const ADAPTERS: Record<ChannelProvider, ChannelAdapter | null> = {
waha: wahaAdapter,
meta_cloud: null, // Fase 3b
meta_cloud: metaCloudAdapter,
};
/**
+16
View File
@@ -0,0 +1,16 @@
import { getAdapter } from "@/lib/channels";
async function main() {
const a = getAdapter("meta_cloud");
console.info("isConfigured:", a.isConfigured());
const to = a.resolveRecipient({
isGroup: false, groupChatId: null, phoneNumber: "+55 31 99896-6398", waIdentity: null,
});
console.info("destinatario resolvido:", to);
try {
const r = await a.send({ sessionRef: "ignorado", to: to!, kind: "text", body: "Teste do adapter oficial do DeskcommCRM." });
console.info("ENVIADO:", JSON.stringify(r));
} catch (e) {
console.info("RECUSADO:", e instanceof Error ? e.message : String(e));
}
}
void main();
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAdapter } from "@/lib/channels";
const a = () => getAdapter("meta_cloud");
function configurar() {
vi.stubEnv("META_PHONE_NUMBER_ID", "1103328999528818");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "tok");
vi.stubEnv("META_GRAPH_VERSION", "v22.0");
}
function stubFetch(resposta: unknown, ok = true) {
const spy = vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 400,
json: async () => resposta,
});
vi.stubGlobal("fetch", spy);
return spy;
}
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("adapter meta_cloud — endereçamento", () => {
it("telefone vira E.164 em DÍGITOS, sem + e sem sufixo", () => {
// `@c.us` é do outro canal. Um `+` sobrevivente vira (#131009) na Meta.
expect(a().resolveRecipient({
isGroup: false, groupChatId: null, phoneNumber: "+55 (31) 99896-6398", waIdentity: null,
})).toBe("5531998966398");
});
it("grupo devolve null — a API de grupos não faz parte deste seam", () => {
expect(a().resolveRecipient({
isGroup: true, groupChatId: "123@g.us", phoneNumber: "+5531999998888", waIdentity: null,
})).toBeNull();
});
it("sem telefone devolve null — não há `lid` neste canal", () => {
expect(a().resolveRecipient({
isGroup: false, groupChatId: null, phoneNumber: null, waIdentity: "lid:12345",
})).toBeNull();
});
});
describe("adapter meta_cloud — configuração", () => {
it("sem credencial NÃO está configurado", () => {
vi.stubEnv("META_PHONE_NUMBER_ID", "");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "");
expect(a().isConfigured()).toBe(false);
});
it("com credencial está configurado", () => {
configurar();
expect(a().isConfigured()).toBe(true);
});
it("não configurado é NOOP no envio, nunca exceção", async () => {
// Mesmo contrato do outro canal: a UI mostra banner, o handler grava `queued`.
vi.stubEnv("META_PHONE_NUMBER_ID", "");
vi.stubEnv("META_SYSTEM_USER_TOKEN", "");
const r = await a().send({ sessionRef: "x", to: "5531999", kind: "text", body: "oi" });
expect(r).toEqual({ externalId: null });
});
it("os códigos carregam o nome do provider — por isso vivem no adapter", () => {
expect(a().codes.notConfigured).toContain("meta");
expect(a().codes.sendFailed).toContain("meta");
});
});
describe("adapter meta_cloud — envio", () => {
it("texto vai como type:text e o phone_number_id entra na URL, não no corpo", async () => {
configurar();
const spy = stubFetch({ messages: [{ id: "wamid.T" }] });
const r = await a().send({ sessionRef: "ignorado", to: "5531998966398", kind: "text", body: "oi" });
expect(r).toEqual({ externalId: "wamid.T" });
const [url, init] = spy.mock.calls[0]!;
expect(url).toContain("/v22.0/1103328999528818/messages");
const corpo = JSON.parse(init.body as string) as Record<string, unknown>;
expect(corpo).toMatchObject({ messaging_product: "whatsapp", to: "5531998966398", type: "text" });
expect(corpo).not.toHaveProperty("session");
});
it("áudio leva voice:true — sem isso vira anexo de música, não nota de voz", async () => {
configurar();
const spy = stubFetch({ messages: [{ id: "wamid.A" }] });
await a().send({
sessionRef: "x", to: "5531998966398", kind: "audio",
media: { url: "https://x/a.ogg", mime: "audio/ogg" },
});
const corpo = JSON.parse(spy.mock.calls[0]![1].body as string) as {
type: string; audio: { link: string; voice: boolean };
};
expect(corpo.type).toBe("audio");
expect(corpo.audio.voice).toBe(true);
});
it("imagem leva caption; documento leva filename", async () => {
configurar();
const spy = stubFetch({ messages: [{ id: "wamid.I" }] });
await a().send({
sessionRef: "x", to: "5531", kind: "image",
media: { url: "https://x/a.jpg", mime: "image/jpeg", caption: "olha" },
});
expect(JSON.parse(spy.mock.calls[0]![1].body as string).image).toEqual({
link: "https://x/a.jpg", caption: "olha",
});
const spy2 = stubFetch({ messages: [{ id: "wamid.D" }] });
await a().send({
sessionRef: "x", to: "5531", kind: "file",
media: { url: "https://x/a.pdf", mime: "application/pdf", filename: "contrato.pdf" },
});
expect(JSON.parse(spy2.mock.calls[0]![1].body as string).document).toMatchObject({
filename: "contrato.pdf",
});
});
it("erro da Meta lança com o `details`, que diz QUAL parâmetro divergiu", async () => {
configurar();
stubFetch(
{
error: {
code: 131009,
message: "Parameter value is not valid",
error_data: { details: "to: número em formato inválido" },
},
},
false,
);
await expect(
a().send({ sessionRef: "x", to: "+5531", kind: "text", body: "oi" }),
).rejects.toThrow(/131009.*formato inválido/);
});
it("resposta sem id devolve externalId null, sem estourar", async () => {
configurar();
stubFetch({ messages: [] });
const r = await a().send({ sessionRef: "x", to: "5531", kind: "text", body: "oi" });
expect(r).toEqual({ externalId: null });
});
});