mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
b11cfc8533
commit
8ec69d8707
@@ -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.
|
||||||
@@ -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,12 +2,13 @@
|
|||||||
* A porta de entrada do seam. Feature nenhuma importa `lib/waha/*` direto —
|
* A porta de entrada do seam. Feature nenhuma importa `lib/waha/*` direto —
|
||||||
* pede o adapter do provider da conversa e o descritor de capabilities.
|
* pede o adapter do provider da conversa e o descritor de capabilities.
|
||||||
*/
|
*/
|
||||||
|
import { metaCloudAdapter } from "./adapters/meta-cloud";
|
||||||
import { wahaAdapter } from "./adapters/waha";
|
import { wahaAdapter } from "./adapters/waha";
|
||||||
import type { ChannelAdapter, ChannelProvider } from "./types";
|
import type { ChannelAdapter, ChannelProvider } from "./types";
|
||||||
|
|
||||||
const ADAPTERS: Record<ChannelProvider, ChannelAdapter | null> = {
|
const ADAPTERS: Record<ChannelProvider, ChannelAdapter | null> = {
|
||||||
waha: wahaAdapter,
|
waha: wahaAdapter,
|
||||||
meta_cloud: null, // Fase 3b
|
meta_cloud: metaCloudAdapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user