Skip to content

Commit 34c1a97

Browse files
authored
Merge pull request #200 from jmpo/feat/canal-zernio
feat(canais): um terceiro canal — vocabulário, envio, definições e entrada
2 parents fdafb7d + 5aa7c4e commit 34c1a97

22 files changed

Lines changed: 2216 additions & 15 deletions

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,19 @@ META_WEBHOOK_VERIFY_TOKEN=
188188
# Versão da Graph API. Explícita de propósito: bump é decisão, não deriva.
189189
META_GRAPH_VERSION=v22.0
190190

191+
# --- Canal via intermediário (BSP) — opcional ---
192+
# Deixe em branco se você não usa este canal; nada quebra sem estas três.
193+
#
194+
# As DUAS de baixo andam juntas: o adapter só se considera configurado com as
195+
# duas preenchidas. Preencher só uma não é meio-caminho — é o estado que fazia a
196+
# mensagem ser marcada como enviada sem sair, e por isso a checagem agora exige
197+
# o par. A credencial também pode viver na SESSÃO (cifrada), e aí estas ficam
198+
# vazias.
199+
ZERNIO_ACCOUNT_ID=
200+
ZERNIO_API_KEY=
201+
# Só para apontar para homologação. Vazio usa a produção do provedor.
202+
ZERNIO_API_BASE_URL=
203+
191204
# --- Teto de login por IP (SÓ para CI de e2e) ---
192205
# NÃO defina isto em produção nem numa VPS. O default (60 tentativas por IP a
193206
# cada 5 min) é o valor correto para uso real e já é folgado por causa de NAT.

app/api/v1/messages/_handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export async function sendMessageHandler(
240240
// envio com 42703. Sem a coluna, nada está arquivado — e a consulta sem ela é a
241241
// consulta certa (ver lib/channels/archived).
242242
const convSelect = (comArchived: boolean) =>
243-
`id, organization_id, contact_id, channel_session_id, is_group, group_chat_id, contacts:contact_id(phone_number, wa_identity, wa_lid, is_blocked), channel_sessions:channel_session_id(${CHANNEL_SESSION_REF_COLUMNS}, status${comArchived ? `, ${ARCHIVED_AT}` : ""})`;
243+
`id, organization_id, contact_id, channel_session_id, is_group, group_chat_id, provider_conversation_id, contacts:contact_id(phone_number, wa_identity, wa_lid, is_blocked), channel_sessions:channel_session_id(${CHANNEL_SESSION_REF_COLUMNS}, status${comArchived ? `, ${ARCHIVED_AT}` : ""})`;
244244
const { data: conv, error: convErr } = await queryTolerantToMissingArchived(
245245
() => supabase.from("conversations").select(convSelect(true)).eq("id", input.conversation_id).maybeSingle(),
246246
() => supabase.from("conversations").select(convSelect(false)).eq("id", input.conversation_id).maybeSingle(),
@@ -260,6 +260,8 @@ export async function sendMessageHandler(
260260
channel_session_id: string;
261261
is_group: boolean;
262262
group_chat_id: string | null;
263+
/** Thread do provider, quando ele endereça por thread própria (migration 0132). */
264+
provider_conversation_id: string | null;
263265
contacts: {
264266
phone_number: string | null;
265267
wa_identity: string | null;
@@ -431,6 +433,7 @@ export async function sendMessageHandler(
431433
({ externalId } = await adapter.send({
432434
sessionRef: resolveSessionRef(c.channel_sessions),
433435
to: chatId,
436+
providerConversationId: c.provider_conversation_id,
434437
kind: input.type,
435438
media: {
436439
url: signed.signedUrl,
@@ -443,6 +446,7 @@ export async function sendMessageHandler(
443446
({ externalId } = await adapter.send({
444447
sessionRef: resolveSessionRef(c.channel_sessions),
445448
to: chatId,
449+
providerConversationId: c.provider_conversation_id,
446450
kind: input.type,
447451
body: input.body ?? "",
448452
}));

lib/channels/adapters/zernio.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Adapter do canal intermediado — o transporte de um BSP.
3+
*
4+
* Burro como os dois irmãos: traduz formato e nada mais. Se aparecer aqui um
5+
* `if` sobre janela de 24h, cap diário ou horário, o desenho vazou — essas
6+
* regras vivem na cadeia `before_send` (doutrina `restricao-de-canal.md`).
7+
*
8+
* ─── A diferença que morde quem copia o adapter do canal oficial ────────────
9+
*
10+
* Os dois canais existentes DERIVAM o destinatário do contato: um monta o
11+
* chatId a partir do telefone, o outro usa o E.164 em dígitos. **Este não.**
12+
* Quem endereça é um id de thread que o intermediário inventa, e que chega pelo
13+
* webhook. Medido contra a API real, não lido da doc:
14+
*
15+
* POST /v1/inbox/conversations/6a3580f68fcd5b3a5b946bf8/messages → 200
16+
* { success: true, data: { messageId: "wamid.HBgMNTk1...", conversationId } }
17+
*
18+
* Por isso `send` exige `providerConversationId`. Sem ele NÃO existe envio de
19+
* texto livre: o endpoint que aceita telefone exige template e devolve
20+
* `TEMPLATE_REQUIRED`, que é o caminho de reengajamento, não o de resposta.
21+
*
22+
* `resolveRecipient` continua devolvendo o telefone porque é o que identifica o
23+
* contato para o resto do sistema (dedup de eco, log, abertura de conversa por
24+
* template) — mas não é o que endereça este envio.
25+
*
26+
* ─── Duas coisas medidas na API, não supostas ───────────────────────────────
27+
*
28+
* 1. O `messageId` devolvido é um **wamid da Meta**, não um id do
29+
* intermediário. É o mesmo espaço de identificador do canal oficial, então
30+
* o eco do webhook casa direto e não precisa de `echoExternalIds`.
31+
* 2. Os dois endpoints devolvem a MESMA forma (`data.messageId`), mas com
32+
* status HTTP diferentes — 201 ao abrir a conversa, 200 ao responder nela.
33+
* Ler `res.ok` e não o código exato é o que faz os dois caminhos
34+
* conviverem.
35+
*/
36+
import { createAdminClient } from "@/lib/supabase/admin";
37+
38+
import { resolveZernioCreds, zernioCredsFromEnv } from "../zernio/credentials";
39+
import { zernioTemplateOps } from "../zernio/templates";
40+
import type { ChannelAdapter, OutboundEnvelope, RecipientInput } from "../types";
41+
42+
/** Só dígitos. `+595 (99) 173-3685` → `595991733685`. */
43+
function toE164Digits(raw: string): string {
44+
return raw.replace(/\D/g, "");
45+
}
46+
47+
/** `kind` do envelope → o par (attachmentType, voiceNote) que a API espera. */
48+
function attachmentFields(env: OutboundEnvelope): Record<string, unknown> {
49+
if (!env.media) return {};
50+
const base: Record<string, unknown> = {
51+
attachmentUrl: env.media.url,
52+
...(env.media.filename ? { attachmentName: env.media.filename } : {}),
53+
...(env.media.caption ? { message: env.media.caption } : {}),
54+
};
55+
switch (env.kind) {
56+
case "image":
57+
return { ...base, attachmentType: "image" };
58+
case "video":
59+
return { ...base, attachmentType: "video" };
60+
case "audio":
61+
// `voiceNote: true` é o que faz virar BOLHA DE VOZ. A API aceita a flag
62+
// mas NÃO converte: exige ogg/opus mono, igual ao canal oficial. Mandar
63+
// mp3 com a flag entrega anexo de música — por isso a capability declara
64+
// `opus-only`, e a conversão é de quem prepara a mídia, não daqui.
65+
return { ...base, attachmentType: "audio", voiceNote: true };
66+
default:
67+
return { ...base, attachmentType: "file" };
68+
}
69+
}
70+
71+
export const zernioAdapter: ChannelAdapter = {
72+
provider: "zernio",
73+
74+
/**
75+
* Telefone em dígitos — é o `participantId` da API.
76+
*
77+
* Grupo devolve `null`: a API de grupos deste canal é outro recurso
78+
* (`/wa-groups`), com id próprio, e fingir que um chatId de grupo cabe aqui
79+
* mandaria a mensagem para o lugar errado.
80+
*/
81+
resolveRecipient(input: RecipientInput): string | null {
82+
if (input.isGroup) return null;
83+
const doIdentity = input.waIdentity?.startsWith("phone:")
84+
? input.waIdentity.slice("phone:".length)
85+
: null;
86+
const bruto = doIdentity ?? input.phoneNumber ?? null;
87+
if (!bruto) return null;
88+
const digitos = toE164Digits(bruto);
89+
return digitos.length > 0 ? digitos : null;
90+
},
91+
92+
/**
93+
* Síncrono de propósito, como no canal oficial: responde "dá para tentar?"
94+
* sem tocar o banco. A credencial gravada na SESSÃO é resolvida de novo
95+
* dentro de `send`, que é async — devolver `false` aqui com sessão
96+
* configurada faria o handler gravar `queued` sem motivo.
97+
*/
98+
isConfigured(): boolean {
99+
// SÓ `zernioCredsFromEnv()`, sem o `|| !!process.env.ZERNIO_API_KEY` que
100+
// havia aqui. O par que precisa ficar fechado é
101+
// `isConfigured() === true ⟹ zernioCredsFromEnv() !== null`,
102+
// porque `send()` devolve `{externalId: null}` SEM lançar quando a credencial
103+
// falta (contrato de "canal não conectado"), e o handler só olha se houve
104+
// throw: ele grava `status:'sent'` incondicionalmente.
105+
//
106+
// Com o `||`, um `.env` com só `ZERNIO_API_KEY` (e sem `ZERNIO_ACCOUNT_ID`)
107+
// fazia a mensagem ser marcada como ENVIADA com zero chamadas de rede —
108+
// medido na triagem, contra o canal oficial como controle, que cai em
109+
// `queued`/`meta_not_configured` na mesma má configuração porque
110+
// `meta-cloud.ts` mantém o par fechado.
111+
return zernioCredsFromEnv() !== null;
112+
},
113+
114+
async send(envelope: OutboundEnvelope): Promise<{ externalId: string | null }> {
115+
const admin = createAdminClient();
116+
const creds = await resolveZernioCreds(admin, envelope.sessionRef);
117+
if (!creds) return { externalId: null };
118+
119+
// Sem thread conhecida não há envio livre. Falhar aqui, com mensagem que
120+
// nomeia o motivo, é melhor que montar uma URL com `undefined` e receber um
121+
// 404 que ninguém consegue interpretar seis meses depois.
122+
if (!envelope.providerConversationId) {
123+
throw new Error(
124+
"zernio_no_conversation: envio livre exige a thread do provider; " +
125+
"abra a conversa com um template antes (a thread chega no webhook).",
126+
);
127+
}
128+
129+
const url =
130+
`${creds.baseUrl}/v1/inbox/conversations/` +
131+
`${encodeURIComponent(envelope.providerConversationId)}/messages`;
132+
133+
const body: Record<string, unknown> = {
134+
accountId: creds.accountId,
135+
...(envelope.media ? attachmentFields(envelope) : { message: envelope.body ?? "" }),
136+
};
137+
138+
const res = await fetch(url, {
139+
method: "POST",
140+
headers: {
141+
Authorization: `Bearer ${creds.apiKey}`,
142+
"Content-Type": "application/json",
143+
},
144+
body: JSON.stringify(body),
145+
});
146+
147+
const json = (await res.json().catch(() => null)) as {
148+
success?: boolean;
149+
data?: { messageId?: string };
150+
error?: string;
151+
code?: string;
152+
} | null;
153+
154+
if (!res.ok || json?.success === false) {
155+
// O `code` do provider entra na mensagem quando existe: é ele que
156+
// distingue "fora da janela" de "número bloqueado" de "conta suspensa", e
157+
// sem isso o operador vê só "falhou".
158+
const detalhe = json?.code ? `${json.code}: ${json.error ?? ""}` : (json?.error ?? res.statusText);
159+
throw new Error(`zernio_send_failed: ${res.status} ${detalhe}`.trim());
160+
}
161+
162+
// 201 ao abrir a conversa, 200 ao responder nela — os dois caminhos
163+
// devolvem a mesma forma, então quem lê não precisa saber qual foi.
164+
return { externalId: json?.data?.messageId ?? null };
165+
},
166+
167+
/** Gestão das definições aprovadas — ver `../zernio/templates.ts`. */
168+
templates: zernioTemplateOps,
169+
170+
codes: {
171+
notConfigured: "zernio_not_configured",
172+
sendFailed: "zernio_error",
173+
unknownError: "zernio_unknown",
174+
},
175+
};

lib/channels/capabilities.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const CHANNEL_CAPABILITIES: Record<ChannelProvider, ChannelCapabilities>
1515
waha: {
1616
freeformOutsideWindow: true,
1717
requiresTemplates: false,
18+
// Não há WABA por trás: não existe definição aprovada para gerir.
19+
canManageTemplates: false,
1820
banRisk: true,
1921
minIntervalMs: null,
2022
voiceNote: "server-convert",
@@ -25,6 +27,42 @@ export const CHANNEL_CAPABILITIES: Record<ChannelProvider, ChannelCapabilities>
2527
meta_cloud: {
2628
freeformOutsideWindow: false,
2729
requiresTemplates: true,
30+
// A Graph API cria e edita definições; o repo hoje só ESPELHA, e é essa
31+
// lacuna que a capability torna visível em vez de deixar implícita.
32+
canManageTemplates: true,
33+
banRisk: false,
34+
minIntervalMs: 6000,
35+
voiceNote: "opus-only",
36+
groups: "limited",
37+
costPerMessage: true,
38+
},
39+
// Mesma hetero-restrição do canal oficial, por baixo: é um BSP: a WABA é da
40+
// Meta, os templates são aprovados pela Meta e a janela de 24h é da Meta. O
41+
// intermediário muda o TRANSPORTE (quem endereça, como se autentica), não o
42+
// que o WhatsApp permite — e capability descreve o permitido, não o encanamento.
43+
//
44+
// As duas diferenças reais, medidas na doc do provider, não na intuição:
45+
//
46+
// - `voiceNote: "opus-only"`. O provider tem um `voiceNote: true` no envio,
47+
// mas exige ogg/opus mono explicitamente e NÃO converte — mesma restrição
48+
// do canal oficial. Ler o campo booleano como "ele resolve para mim" é o
49+
// erro que manda mp3 e entrega anexo de música.
50+
// - `groups: "limited"`. Existe API de grupos, mas só em plano de uso e só
51+
// para números fora de coexistência. Capability é o que a instalação MÉDIA
52+
// pode fazer; prometer "full" aqui quebraria em quem não paga o plano.
53+
// `freeformOutsideWindow: false` está MEDIDO, não deduzido. A API aceita o
54+
// envio livre (200 + wamid) e a Meta recusa a ENTREGA depois, pelo webhook:
55+
//
56+
// 131047 Re-engagement message — "The 24-hour customer service window for
57+
// this contact is closed. Send an approved template to re-open the
58+
// conversation, or wait for the contact to message you first."
59+
//
60+
// O detalhe que engana: mandar um template NÃO abre a janela. Só o cliente
61+
// abre, respondendo. Quem ler o 200 como "enviado" acha que funciona.
62+
zernio: {
63+
freeformOutsideWindow: false,
64+
requiresTemplates: true,
65+
canManageTemplates: true,
2866
banRisk: false,
2967
minIntervalMs: 6000,
3068
voiceNote: "opus-only",
@@ -51,6 +89,7 @@ export const DEFAULT_CHANNEL_PROVIDER: ChannelProvider = "waha";
5189
*/
5290
export const CHANNEL_PROVIDER_WAHA: ChannelProvider = "waha";
5391
export const CHANNEL_PROVIDER_META: ChannelProvider = "meta_cloud";
92+
export const CHANNEL_PROVIDER_ZERNIO: ChannelProvider = "zernio";
5493

5594
export function capabilitiesOf(provider: ChannelProvider): ChannelCapabilities {
5695
const caps = CHANNEL_CAPABILITIES[provider];

lib/channels/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
*/
55
import { metaCloudAdapter } from "./adapters/meta-cloud";
66
import { wahaAdapter } from "./adapters/waha";
7+
import { zernioAdapter } from "./adapters/zernio";
78
import type { ChannelAdapter, ChannelProvider } from "./types";
89

910
const ADAPTERS: Record<ChannelProvider, ChannelAdapter | null> = {
1011
waha: wahaAdapter,
1112
meta_cloud: metaCloudAdapter,
13+
zernio: zernioAdapter,
1214
};
1315

1416
/**

lib/channels/session-ref.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,27 @@
1313
*/
1414
export type ChannelSessionRef =
1515
| { provider: "waha"; waha_session_name: string }
16-
| { provider: "meta_cloud"; meta_phone_number_id: string };
16+
| { provider: "meta_cloud"; meta_phone_number_id: string }
17+
| { provider: "zernio"; zernio_account_id: string };
1718

1819
/**
1920
* Colunas que um `select` do PostgREST precisa trazer para `resolveSessionRef`
2021
* funcionar. Fica aqui pelo mesmo motivo da função: a string do `select` também
2122
* nomeia coluna de provider, e ela some da feature junto com a decisão.
2223
*/
23-
export const CHANNEL_SESSION_REF_COLUMNS = "provider, waha_session_name, meta_phone_number_id";
24+
export const CHANNEL_SESSION_REF_COLUMNS =
25+
"provider, waha_session_name, meta_phone_number_id, zernio_account_id";
2426

2527
export function resolveSessionRef(session: ChannelSessionRef): string {
2628
switch (session.provider) {
2729
case "meta_cloud":
2830
return session.meta_phone_number_id;
2931
case "waha":
3032
return session.waha_session_name;
33+
// O `accountId` que o provider devolve ao conectar a WABA. NÃO é o
34+
// phone_number_id da Meta: quem intermedeia guarda o número por dentro e
35+
// endereça pelo id dele. Mandar o id da Meta aqui responde 404.
36+
case "zernio":
37+
return session.zernio_account_id;
3138
}
3239
}

0 commit comments

Comments
 (0)