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 —
|
||||
* 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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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