Explorar o código

feat: migração de chat de testes do Baileys para Cloud API oficial da Meta

leonardo hai 1 mes
pai
achega
458020871d

+ 19 - 3
.env.example

@@ -110,9 +110,25 @@ ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
 # ATENDIMENTOS_RAG_MIN_SCORE com perguntas reais após o backfill inicial
 # ATENDIMENTOS_RAG_SEARCH_ENABLED=false
 
-# WhatsApp (Baileys — conexão via QR code, sem token/segredo nenhum). O estado da sessão
-# fica em backend/data/baileys-session/ (não versionado). Conectar/desconectar pelo painel
-# admin em /api/whatsapp (status, conectar, desconectar).
+# WhatsApp Cloud API (Meta) — usada pelo chat de Atendimentos (/api/conversas) e pelo
+# webhook em /webhooks/whatsapp (fora de /api, sem JWT — protegido por hub.verify_token
+# na verificação e, opcionalmente, assinatura HMAC no recebimento).
+#
+# Valores vêm do painel da Meta (developers.facebook.com/apps/<seu-app>/whatsapp-business/wa-dev-console/):
+# - WHATSAPP_PHONE_NUMBER_ID e WHATSAPP_TOKEN: aba "API Setup" (o token temporário expira
+#   em 24h; gere um token permanente em System Users antes de ir pra produção)
+# - WHATSAPP_VERIFY_TOKEN: valor arbitrário definido por você — cole o mesmo valor no
+#   painel da Meta em WhatsApp → Configuration → Webhook → "Verify token"
+# - WHATSAPP_APP_SECRET: aba "App settings → Basic", campo "App Secret" — valida a
+#   assinatura X-Hub-Signature-256 dos eventos recebidos; vazio desativa a validação
+# - WHATSAPP_BUSINESS_ACCOUNT_ID (opcional, não usado ainda): mesma tela do API Setup,
+#   reservado para gestão de templates/perfil de negócio no futuro
+WHATSAPP_API_VERSION=v21.0
+WHATSAPP_PHONE_NUMBER_ID=
+WHATSAPP_TOKEN=
+WHATSAPP_VERIFY_TOKEN=
+WHATSAPP_APP_SECRET=
+# WHATSAPP_BUSINESS_ACCOUNT_ID=
 
 # Integrador IXC (Alfred) — usadas só pelo seed (npm run seed:run), que provisiona a
 # linha na tabela "integracao". Em runtime o service lê Url/Login/Senha/Token do banco,

+ 12 - 0
db/migrations/20260805130000_widen_whatsapp_message_id.cjs

@@ -0,0 +1,12 @@
+exports.up = function (knex) {
+
+  return knex.schema.alterTable("whatsapp_mensagens", (t) => {
+    t.string("WhatsappMessageId", 191).nullable().alter();
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("whatsapp_mensagens", (t) => {
+    t.string("WhatsappMessageId", 100).nullable().alter();
+  });
+};

+ 0 - 2
package.json

@@ -18,7 +18,6 @@
     "@qdrant/js-client-rest": "^1.11.0",
     "axios": "^1.19.0",
     "axios-retry": "^4.5.0",
-    "baileys": "^6.7.23",
     "bcryptjs": "^2.4.3",
     "cors": "^2.8.5",
     "dotenv": "^16.4.5",
@@ -33,7 +32,6 @@
     "objection": "^3.1.5",
     "objection-unique": "^1.2.2",
     "pdf-parse": "^1.1.1",
-    "qrcode": "^1.5.4",
     "zod": "^3.23.8"
   },
   "engines": {

+ 18 - 1
src/config/index.js

@@ -108,7 +108,15 @@ export const config = {
     historyLimit: Number(process.env.ATENDENTE_SUGESTAO_HISTORY_LIMIT ?? 20),
     maxChars: Number(process.env.ATENDENTE_SUGESTAO_MAX_CHARS ?? 8000)
   },
- 
+  whatsapp: {
+    apiVersion: process.env.WHATSAPP_API_VERSION || "v21.0",
+    phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID ?? "",
+    token: process.env.WHATSAPP_TOKEN ?? "",
+    verifyToken: process.env.WHATSAPP_VERIFY_TOKEN ?? "",
+    appSecret: process.env.WHATSAPP_APP_SECRET ?? "",
+    businessAccountId: process.env.WHATSAPP_BUSINESS_ACCOUNT_ID ?? ""
+  },
+
   ixcIntegradorSeed: {
     baseUrl: process.env.IXC_INTEGRADOR_BASE_URL ?? "",
     login: process.env.IXC_INTEGRADOR_LOGIN ?? "",
@@ -127,6 +135,15 @@ function validateConfig(cfg) {
   if (cfg.rateLimit.perMinute === 0) {
     console.warn("[config] RATE_LIMIT_PER_MINUTE=0 — rate limiting desabilitado");
   }
+  if (!cfg.whatsapp.token || !cfg.whatsapp.phoneNumberId) {
+    console.warn("[config] WHATSAPP_TOKEN/WHATSAPP_PHONE_NUMBER_ID não configurados — envio via WhatsApp ficará desabilitado (whatsapp_nao_conectado)");
+  }
+  if (!cfg.whatsapp.verifyToken) {
+    console.warn("[config] WHATSAPP_VERIFY_TOKEN não configurado — a verificação do webhook (GET /webhooks/whatsapp) sempre retornará 403");
+  }
+  if (!cfg.whatsapp.appSecret) {
+    console.warn("[config] WHATSAPP_APP_SECRET vazio — assinatura X-Hub-Signature-256 do webhook NÃO será validada");
+  }
 }
 
 validateConfig(config);

+ 2 - 20
src/controllers/WhatsappConexao.Controller.js

@@ -1,27 +1,9 @@
-import { iniciarConexao, obterStatus, desconectar } from "../services/whatsappService.js";
+import { obterStatus } from "../services/whatsappService.js";
 
 export const WhatsappConexaoController = {
   Status: async function (req, res, next) {
     try {
-      res.json(obterStatus());
-    } catch (err) {
-      next(err);
-    }
-  },
-
-  Conectar: async function (req, res, next) {
-    try {
-      await iniciarConexao();
-      res.status(202).json(obterStatus());
-    } catch (err) {
-      next(err);
-    }
-  },
-
-  Desconectar: async function (req, res, next) {
-    try {
-      await desconectar();
-      res.json(obterStatus());
+      res.json(await obterStatus());
     } catch (err) {
       next(err);
     }

+ 6 - 8
src/factories/Server.factory.js

@@ -12,16 +12,14 @@ import { scheduleCleanup } from "../jobs/cleanupTokens.js";
 import { scheduleSyncAtendimentos } from "../jobs/syncAtendimentos.js";
 import { scheduleAvaliarAtendimentos } from "../jobs/avaliarAtendimentos.js";
 import { scheduleIngestAtendimentosRag } from "../jobs/ingestAtendimentosRag.js";
-import { iniciarConexaoSalva } from "../services/whatsappService.js";
+import { whatsappWebhookRouter } from "../routes/WhatsappWebhook.Rotas.js";
 
 export class ServerFactory {
   static Iniciar() {
     const app = express();
 
     app.disable("x-powered-by");
-    // o front roda em origem/porta diferente do back (dev: 5173 vs 3001) e busca mídia
-    // (imagem/áudio) via fetch autenticado — o padrão "same-origin" do helmet faz o
-    // navegador bloquear essa resposta mesmo com CORS liberado, travando o fetch pra sempre
+
     app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
     app.use(
       cors({
@@ -29,6 +27,9 @@ export class ServerFactory {
         credentials: true
       })
     );
+   
+    app.use("/webhooks/whatsapp", whatsappWebhookRouter);
+
     app.use(express.json({ limit: "10mb" }));
     app.use(morgan("dev"));
 
@@ -63,12 +64,9 @@ export class ServerFactory {
       scheduleSyncAtendimentos();
       scheduleAvaliarAtendimentos();
       scheduleIngestAtendimentosRag();
-      iniciarConexaoSalva().catch((e) => console.error("[whatsapp] falha ao reconectar sessão salva:", e.message));
     });
 
-    // sem isso, EADDRINUSE (porta já ocupada por outro backend rodando) sobe como
-    // exceção não tratada — stack trace confuso e nada explica que é só outro processo
-    // já de pé; aqui a causa fica clara e o processo encerra igual, sem crash "seco"
+    
     server.on("error", (err) => {
       if (err.code === "EADDRINUSE") {
         console.error(`\n[server] Porta ${port} já está em uso — outro backend já deve estar rodando. Encerre-o antes de iniciar de novo.\n`);

+ 1 - 5
src/routes/IxcIntegrador.Rotas.js

@@ -5,11 +5,7 @@ import { requireUser } from "../middleware/RequireUser.js";
 import { requireAdmin } from "../middleware/RequireAdmin.js";
 import { parseId } from "../middleware/ParseId.js";
 import { validate } from "../middleware/Validate.js";
-import {
-  ixcClienteBuscaSchema,
-  ixcContratoDetalhesQuerySchema,
-  ixcOsBuscaAvancadaSchema
-} from "../middleware/schemas/IxcIntegrador.Schema.js";
+import { ixcClienteBuscaSchema, ixcContratoDetalhesQuerySchema, ixcOsBuscaAvancadaSchema } from "../middleware/schemas/IxcIntegrador.Schema.js";
 
 export const ixcIntegradorRouter = Router();
 

+ 0 - 2
src/routes/WhatsappConexao.Rotas.js

@@ -6,7 +6,5 @@ import { requireAdmin } from "../middleware/RequireAdmin.js";
 export const whatsappConexaoRouter = Router();
 
 whatsappConexaoRouter.get("/status", requireUser, requireAdmin, WhatsappConexaoController.Status);
-whatsappConexaoRouter.post("/conectar", requireUser, requireAdmin, WhatsappConexaoController.Conectar);
-whatsappConexaoRouter.post("/desconectar", requireUser, requireAdmin, WhatsappConexaoController.Desconectar);
 
 export default whatsappConexaoRouter;

+ 70 - 0
src/routes/WhatsappWebhook.Rotas.js

@@ -0,0 +1,70 @@
+import { Router } from "express";
+import express from "express";
+import crypto from "node:crypto";
+import { config } from "../config/index.js";
+import { rateLimitMiddleware } from "../middleware/RateLimit.js";
+import { processarMensagemWebhook } from "../services/whatsappService.js";
+
+export const whatsappWebhookRouter = Router();
+
+
+const rawJsonParser = express.json({
+  limit: "10mb",
+  verify: (req, _res, buf) => {
+    req.rawBody = buf;
+  }
+});
+
+whatsappWebhookRouter.use(rateLimitMiddleware);
+
+
+whatsappWebhookRouter.get("/", (req, res) => {
+  const modo = req.query["hub.mode"];
+  const token = req.query["hub.verify_token"];
+
+  if (modo === "subscribe" && config.whatsapp.verifyToken && token === config.whatsapp.verifyToken) {
+    res.status(200).send(req.query["hub.challenge"]);
+    return;
+  }
+
+  res.sendStatus(403);
+});
+
+function assinaturaValida(req) {
+
+  if (!config.whatsapp.appSecret) return true;
+
+  const recebida = req.header("x-hub-signature-256") ?? "";
+  const esperada =
+    "sha256=" + crypto.createHmac("sha256", config.whatsapp.appSecret).update(req.rawBody ?? Buffer.alloc(0)).digest("hex");
+
+  const bufRecebida = Buffer.from(recebida);
+  const bufEsperada = Buffer.from(esperada);
+  if (bufRecebida.length !== bufEsperada.length) return false;
+
+  return crypto.timingSafeEqual(bufRecebida, bufEsperada);
+}
+
+whatsappWebhookRouter.post("/", rawJsonParser, async (req, res) => {
+  if (!assinaturaValida(req)) {
+    console.warn("[whatsapp-webhook] assinatura inválida — evento ignorado");
+    res.sendStatus(401);
+    return;
+  }
+
+  res.sendStatus(200);
+
+  try {
+    for (const entrada of req.body?.entry ?? []) {
+      for (const change of entrada.changes ?? []) {
+        if (change.field === "messages") {
+          await processarMensagemWebhook(change.value ?? {});
+        }
+      }
+    }
+  } catch (err) {
+    console.error("[whatsapp-webhook] falha ao processar evento:", err);
+  }
+});
+
+export default whatsappWebhookRouter;

+ 49 - 0
src/services/whatsappCloudClient.js

@@ -0,0 +1,49 @@
+import axios from "axios";
+import axiosRetry from "axios-retry";
+import { config } from "../config/index.js";
+
+function client() {
+  const instance = axios.create({
+    baseURL: `https://graph.facebook.com/${config.whatsapp.apiVersion}`,
+    timeout: 15_000,
+    headers: { Authorization: `Bearer ${config.whatsapp.token}` }
+  });
+
+  axiosRetry(instance, {
+    retries: 2,
+    retryDelay: axiosRetry.exponentialDelay,
+  
+    retryCondition: (error) => !error.response && (error.code === "ECONNABORTED" || axiosRetry.isNetworkError(error))
+  });
+
+  return instance;
+}
+
+export async function enviarTexto(paraTelefone, texto) {
+  const { data } = await client().post(`/${config.whatsapp.phoneNumberId}/messages`, {
+    messaging_product: "whatsapp",
+    recipient_type: "individual",
+    to: paraTelefone,
+    type: "text",
+    text: { preview_url: false, body: texto }
+  });
+  return data;
+}
+
+export async function obterUrlMidia(mediaId) {
+  const { data } = await client().get(`/${mediaId}`);
+  return data;
+}
+
+export async function baixarBytesMidia(url) {
+  
+  const { data, headers } = await client().get(url, { responseType: "arraybuffer" });
+  return { buffer: Buffer.from(data), mimetype: headers["content-type"] ?? null };
+}
+
+export async function obterInfoNumero() {
+  const { data } = await client().get(`/${config.whatsapp.phoneNumberId}`, {
+    params: { fields: "verified_name,display_phone_number,quality_rating" }
+  });
+  return data;
+}

+ 134 - 171
src/services/whatsappService.js

@@ -1,21 +1,20 @@
 import path from "node:path";
 import fs from "node:fs";
 import { randomUUID } from "node:crypto";
-import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from "baileys";
-import qrcode from "qrcode";
+import { config } from "../config/index.js";
+import { enviarTexto, obterUrlMidia, baixarBytesMidia, obterInfoNumero } from "./whatsappCloudClient.js";
 import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { BadRequestError, NotFoundError } from "../shared/errors/index.js";
 
-const SESSION_DIR = path.resolve("data/baileys-session");
 const MEDIA_DIR = path.resolve("data/whatsapp-media");
 
-const CAMPOS_MIDIA = {
-  imageMessage: "imagem",
-  videoMessage: "video",
-  audioMessage: "audio",
-  stickerMessage: "sticker",
-  documentMessage: "documento"
+const TIPOS_MIDIA = {
+  image: "imagem",
+  video: "video",
+  audio: "audio",
+  sticker: "sticker",
+  document: "documento"
 };
 
 const EXTENSOES_POR_MIMETYPE = {
@@ -36,43 +35,33 @@ function extensaoPorMimetype(mimetype) {
   return EXTENSOES_POR_MIMETYPE[base] ?? "";
 }
 
-function detectarMidia(msgContent) {
-  for (const [campo, tipo] of Object.entries(CAMPOS_MIDIA)) {
-    const parte = msgContent?.[campo];
-    if (parte) {
-      return {
-        tipo,
-        mimetype: parte.mimetype ?? null,
-        caption: typeof parte.caption === "string" ? parte.caption.trim() : "",
-        nomeArquivo: parte.fileName ?? null
-      };
-    }
-  }
-  return null;
+function detectarMidia(m) {
+  const tipo = TIPOS_MIDIA[m.type];
+  if (!tipo) return null;
+  const parte = m[m.type];
+  return {
+    tipo,
+    mediaId: parte.id,
+    mimetype: parte.mime_type ?? null,
+    caption: typeof parte.caption === "string" ? parte.caption.trim() : "",
+    nomeArquivo: parte.filename ?? null
+  };
 }
 
-async function baixarMidia(m, midiaInfo) {
-  const buffer = await downloadMediaMessage(m, "buffer", {}, { reuploadRequest: sock?.updateMediaMessage });
+async function baixarMidia(midiaInfo) {
+  const { url } = await obterUrlMidia(midiaInfo.mediaId);
+  const { buffer, mimetype } = await baixarBytesMidia(url);
   fs.mkdirSync(MEDIA_DIR, { recursive: true });
-  const nomeArquivo = `${randomUUID()}${extensaoPorMimetype(midiaInfo.mimetype)}`;
+  const nomeArquivo = `${randomUUID()}${extensaoPorMimetype(midiaInfo.mimetype ?? mimetype)}`;
   fs.writeFileSync(path.join(MEDIA_DIR, nomeArquivo), buffer);
   return nomeArquivo;
 }
 
-let sock = null;
-let conectando = false;
-let estado = { status: "desconectado", qrDataUrl: null, telefone: null, ultimoErro: null };
-
-function normalizarTelefone(jid) {
-  return jid.split("@")[0].split(":")[0];
-}
-
-async function obterOuCriarConversa(telefone, nomeContato, jid) {
+async function obterOuCriarConversa(telefone, nomeContato) {
   const existente = await WhatsappConversa.query().findOne({ Telefone: telefone });
   if (existente) {
     const patch = {};
     if (nomeContato && nomeContato !== existente.NomeContato) patch.NomeContato = nomeContato;
-    if (jid && jid !== existente.Jid) patch.Jid = jid;
     if (Object.keys(patch).length > 0) {
       await WhatsappConversa.query().findById(existente.Id).patch({ ...patch, UpdatedAt: new Date() });
     }
@@ -83,13 +72,11 @@ async function obterOuCriarConversa(telefone, nomeContato, jid) {
     const criada = await WhatsappConversa.query().insert({
       Telefone: telefone,
       NomeContato: nomeContato ?? null,
-      Jid: jid ?? null,
       Status: "aberta"
     });
     return criada.Id;
   } catch (err) {
-    // corrida entre eventos messages.upsert concorrentes: outra mensagem quase simultânea
-    // do mesmo contato novo já criou a conversa (Telefone é UNIQUE) — reaproveita ela
+   
     if (err?.code === "ER_DUP_ENTRY" || err?.nativeError?.code === "ER_DUP_ENTRY") {
       const criadaPelaCorrida = await WhatsappConversa.query().findOne({ Telefone: telefone });
       if (criadaPelaCorrida) return criadaPelaCorrida.Id;
@@ -98,159 +85,136 @@ async function obterOuCriarConversa(telefone, nomeContato, jid) {
   }
 }
 
-export async function iniciarConexao() {
-  if (sock || conectando) return;
-  conectando = true;
-
-  let state, saveCreds, version;
-  try {
-    fs.mkdirSync(SESSION_DIR, { recursive: true });
-    ({ state, saveCreds } = await useMultiFileAuthState(SESSION_DIR));
-    ({ version } = await fetchLatestBaileysVersion());
-  } catch (err) {
-    conectando = false;
-    throw err;
-  }
-
-  sock = makeWASocket({ auth: state, version });
-  conectando = false;
-  estado.status = "conectando";
+export async function processarMensagemWebhook(value) {
+  const mensagens = value?.messages ?? [];
 
-  sock.ev.on("creds.update", saveCreds);
+  for (const m of mensagens) {
+    const telefone = m?.from;
+    try {
+      const nomeContato = value?.contacts?.find((c) => c.wa_id === telefone)?.profile?.name ?? null;
 
-  sock.ev.on("connection.update", async (update) => {
-    if (update.qr) {
-      estado.qrDataUrl = await qrcode.toDataURL(update.qr);
-      estado.status = "qr_pendente";
-    }
+      let texto = null;
+      let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
 
-    if (update.connection === "open") {
-      estado = { status: "conectado", qrDataUrl: null, telefone: normalizarTelefone(sock.user.id), ultimoErro: null };
-      console.log(`[whatsapp] conectado como ${estado.telefone}`);
-    }
-
-    if (update.connection === "close") {
-      const deslogado = update.lastDisconnect?.error?.output?.statusCode === DisconnectReason.loggedOut;
-      console.log(`[whatsapp] conexão fechada (deslogado=${deslogado}):`, update.lastDisconnect?.error?.message);
-      sock = null;
-
-      if (deslogado) {
-        estado = { status: "desconectado", qrDataUrl: null, telefone: null, ultimoErro: null };
-        fs.rmSync(SESSION_DIR, { recursive: true, force: true });
+      if (m.type === "text") {
+        texto = m.text?.body ?? "";
       } else {
-        estado.status = "desconectado";
-        estado.ultimoErro = update.lastDisconnect?.error?.message ?? null;
-        // queda momentânea (rede, etc.) — tenta reconectar sozinho usando a sessão salva
-        iniciarConexao().catch(() => {});
+        const midiaInfo = detectarMidia(m);
+        if (midiaInfo) {
+          try {
+            const nomeArquivo = await baixarMidia(midiaInfo);
+            midiaCampos = {
+              TipoMidia: midiaInfo.tipo,
+              MidiaPath: nomeArquivo,
+              MidiaMimetype: midiaInfo.mimetype,
+              MidiaNomeArquivo: midiaInfo.nomeArquivo
+            };
+            texto = midiaInfo.caption ?? "";
+          } catch (err) {
+            console.error("[whatsapp] falha ao baixar mídia:", err.message);
+            texto = "[mensagem do tipo não suportada]";
+          }
+        } else {
+          texto = "[mensagem do tipo não suportada]";
+        }
       }
-    }
-  });
-
-  sock.ev.on("messages.upsert", async ({ messages, type }) => {
-    console.log(`[whatsapp] messages.upsert type=${type} count=${messages.length}`);
 
-    for (const m of messages) {
-      if (m.key.fromMe) {
-        console.log("[whatsapp] ignorada: fromMe");
-        continue;
-      }
-      if (m.key.remoteJid?.endsWith("@g.us") || m.key.remoteJid === "status@broadcast") {
-        console.log(`[whatsapp] ignorada: jid=${m.key.remoteJid}`);
-        continue;
-      }
-      if (!m.message) {
-        console.log(`[whatsapp] ignorada: m.message vazio (falha de decriptação ou stub) — messageStubType=${m.messageStubType}, jid=${m.key.remoteJid}`);
+      const timestamp = m.timestamp ? new Date(Number(m.timestamp) * 1000) : new Date();
+      console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
+      const conversaId = await obterOuCriarConversa(telefone, nomeContato);
+
+   
+      const inserted = await WhatsappMensagem.query()
+        .insert({
+          ConversaId: conversaId,
+          Direcao: "entrada",
+          Corpo: texto,
+          WhatsappMessageId: m.id ?? null,
+          Timestamp: timestamp,
+          ...midiaCampos
+        })
+        .onConflict("WhatsappMessageId")
+        .ignore();
+
+      if (!inserted?.Id) {
+        console.log(`[whatsapp] ignorada: WhatsappMessageId duplicado (${m.id})`);
         continue;
       }
 
-      // remoteJid pode ser um LID (identificador interno) em vez do número de telefone real;
-      // quando o WhatsApp informa senderPn, ele traz o JID baseado no número de fato — preferir esse
-      const jid = m.key.senderPn || m.key.remoteJid;
-      const telefone = normalizarTelefone(jid);
-
-      try {
-        let texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? null;
-        let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
-
-        if (texto === null) {
-          const midiaInfo = detectarMidia(m.message);
-          if (midiaInfo) {
-            try {
-              const nomeArquivo = await baixarMidia(m, midiaInfo);
-              midiaCampos = {
-                TipoMidia: midiaInfo.tipo,
-                MidiaPath: nomeArquivo,
-                MidiaMimetype: midiaInfo.mimetype,
-                MidiaNomeArquivo: midiaInfo.nomeArquivo
-              };
-              texto = midiaInfo.caption ?? "";
-            } catch (err) {
-              console.error("[whatsapp] falha ao baixar mídia:", err.message);
-              texto = `[mensagem do tipo não suportada]`;
-            }
-          } else {
-            texto = `[mensagem do tipo não suportada]`;
-          }
-        }
-
-        const timestamp = m.messageTimestamp ? new Date(Number(m.messageTimestamp) * 1000) : new Date();
-        console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
-        const conversaId = await obterOuCriarConversa(telefone, m.pushName, jid);
-
-        // o Baileys pode reentregar a mesma mensagem; key.id é único e o insert é ignorado nesse caso
-        const inserted = await WhatsappMensagem.query()
-          .insert({
-            ConversaId: conversaId,
-            Direcao: "entrada",
-            Corpo: texto,
-            WhatsappMessageId: m.key.id ?? null,
-            Timestamp: timestamp,
-            ...midiaCampos
-          })
-          .onConflict("WhatsappMessageId")
-          .ignore();
-
-        if (!inserted?.Id) {
-          console.log(`[whatsapp] ignorada: WhatsappMessageId duplicado (${m.key.id})`);
-          continue;
-        }
+      await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
+    } catch (err) {
+      
+      console.error(`[whatsapp] falha ao processar mensagem de ${telefone} (id=${m?.id}):`, err);
+    }
+  }
 
-        await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
-      } catch (err) {
-        // uma falha nesta mensagem (ex.: corrida de banco, erro transitório) não pode
-        // derrubar o processamento do restante do lote
-        console.error(`[whatsapp] falha ao processar mensagem de ${telefone} (key.id=${m.key.id}):`, err);
-      }
+  if (Array.isArray(value?.statuses)) {
+    for (const s of value.statuses) {
+      console.log(`[whatsapp] status de entrega recebido: id=${s.id} status=${s.status} destinatario=${s.recipient_id}`);
     }
-  });
+  }
 }
 
-export function obterStatus() {
-  return estado;
-}
 
-export async function desconectar() {
-  if (sock) {
-    await sock.logout().catch(() => {});
-    sock = null;
+function paraFormatoEnvio(telefone) {
+  if (/^55\d{10}$/.test(telefone)) {
+    return `55${telefone.slice(2, 4)}9${telefone.slice(4)}`;
   }
-  fs.rmSync(SESSION_DIR, { recursive: true, force: true });
-  estado = { status: "desconectado", qrDataUrl: null, telefone: null, ultimoErro: null };
+  return telefone;
 }
 
-export async function enviarMensagem(jid, texto) {
-  if (!sock || estado.status !== "conectado") {
+export async function enviarMensagem(telefone, texto) {
+  if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
     throw new BadRequestError("whatsapp_nao_conectado");
   }
 
-  const res = await sock.sendMessage(jid, { text: texto });
-  return res?.key?.id ?? null;
+  try {
+    const data = await enviarTexto(paraFormatoEnvio(telefone), texto);
+    return data?.messages?.[0]?.id ?? null;
+  } catch (err) {
+    const erroMeta = err?.response?.data?.error;
+    if (erroMeta?.code === 131047 || erroMeta?.code === 131026) {
+      throw new BadRequestError(
+        "Mais de 24h desde a última mensagem do cliente — só é possível responder com um modelo (template) aprovado pela Meta."
+      );
+    }
+    throw new BadRequestError(erroMeta?.message ?? "Falha ao enviar mensagem via WhatsApp");
+  }
 }
 
-export async function iniciarConexaoSalva() {
-  if (fs.existsSync(SESSION_DIR) && fs.readdirSync(SESSION_DIR).length > 0) {
-    await iniciarConexao();
+let statusCache = null;
+let statusCacheAt = 0;
+const STATUS_CACHE_MS = 30_000;
+
+export async function obterStatus() {
+  if (statusCache && Date.now() - statusCacheAt < STATUS_CACHE_MS) {
+    return statusCache;
+  }
+
+  if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
+    statusCache = { status: "nao_configurado", telefone: null, verifiedName: null, ultimoErro: null };
+    statusCacheAt = Date.now();
+    return statusCache;
   }
+
+  try {
+    const info = await obterInfoNumero();
+    statusCache = {
+      status: "conectado",
+      telefone: info.display_phone_number ?? null,
+      verifiedName: info.verified_name ?? null,
+      ultimoErro: null
+    };
+  } catch (err) {
+    statusCache = {
+      status: "erro",
+      telefone: null,
+      verifiedName: null,
+      ultimoErro: err?.response?.data?.error?.message ?? err.message
+    };
+  }
+  statusCacheAt = Date.now();
+  return statusCache;
 }
 
 export async function listarConversas() {
@@ -267,8 +231,7 @@ export async function responderConversa(conversaId, texto) {
   const conversa = await WhatsappConversa.query().findById(conversaId);
   if (!conversa) throw new NotFoundError("conversa_not_found");
 
-  const jid = conversa.Jid || `${conversa.Telefone}@s.whatsapp.net`;
-  const whatsappMessageId = await enviarMensagem(jid, texto);
+  const whatsappMessageId = await enviarMensagem(conversa.Telefone, texto);
   const timestamp = new Date();
 
   const mensagem = await WhatsappMensagem.query().insert({
@@ -296,6 +259,6 @@ export async function excluirConversa(conversaId) {
   const conversa = await WhatsappConversa.query().findById(conversaId);
   if (!conversa) throw new NotFoundError("conversa_not_found");
 
-  // whatsapp_mensagens tem ON DELETE CASCADE em ConversaId
+
   await WhatsappConversa.query().deleteById(conversaId);
 }