Bläddra i källkod

adição de chat interno para testar com a ia

leonardo 1 månad sedan
förälder
incheckning
ba6ab09d80

+ 4 - 0
.env.example

@@ -100,3 +100,7 @@ ATENDIMENTOS_SYNC_INTERVAL_MINUTES=30
 # habilita a busca vetorial no modo "atendimentos" do chat — deixar false até calibrar
 # 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).

+ 38 - 0
db/migrations/20260722120000_create_whatsapp_conversas_tables.cjs

@@ -0,0 +1,38 @@
+exports.up = function (knex) {
+  return knex.schema
+    .createTable("whatsapp_conversas", (t) => {
+      t.increments("Id").primary();
+      t.string("Telefone", 30).notNullable();
+      t.string("NomeContato", 255).nullable();
+      t.integer("AtendenteId").unsigned().nullable();
+      t.string("Status", 20).notNullable().defaultTo("aberta");
+      t.timestamp("UltimaMensagemEm").nullable();
+      t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+      t.timestamp("UpdatedAt").notNullable().defaultTo(knex.fn.now());
+
+      t.foreign("AtendenteId").references("usuarios.Id").onDelete("SET NULL");
+      t.unique(["Telefone"]);
+      t.index(["Status"]);
+      t.index(["UltimaMensagemEm"]);
+    })
+    .createTable("whatsapp_mensagens", (t) => {
+      t.increments("Id").primary();
+      t.integer("ConversaId").unsigned().notNullable();
+      t.string("Direcao", 10).notNullable(); // entrada (cliente) | saida (atendente)
+      t.text("Corpo").notNullable();
+      t.string("WhatsappMessageId", 100).nullable(); // id da mensagem na Meta; usado para não duplicar reenvios de webhook
+      t.timestamp("Timestamp").notNullable();
+      t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+
+      t.foreign("ConversaId").references("whatsapp_conversas.Id").onDelete("CASCADE");
+      t.unique(["WhatsappMessageId"]);
+      t.index(["ConversaId"]);
+      t.index(["Timestamp"]);
+    });
+};
+
+exports.down = function (knex) {
+  return knex.schema
+    .dropTable("whatsapp_mensagens")
+    .dropTable("whatsapp_conversas");
+};

+ 14 - 0
db/migrations/20260722190000_add_jid_to_whatsapp_conversas.cjs

@@ -0,0 +1,14 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("whatsapp_conversas", (t) => {
+    // JID completo do Baileys (ex.: "5511999999999@s.whatsapp.net" ou "29042636013810@lid").
+    // Necessário porque alguns contatos são identificados por LID (não pelo número de telefone real)
+    // e enviar para "@s.whatsapp.net" nesse caso não chega no destinatário.
+    t.string("Jid", 60).nullable();
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("whatsapp_conversas", (t) => {
+    t.dropColumn("Jid");
+  });
+};

+ 2 - 0
package.json

@@ -16,6 +16,7 @@
   },
   "dependencies": {
     "@qdrant/js-client-rest": "^1.11.0",
+    "baileys": "^6.7.23",
     "bcryptjs": "^2.4.3",
     "cors": "^2.8.5",
     "dotenv": "^16.4.5",
@@ -30,6 +31,7 @@
     "objection": "^3.1.5",
     "objection-unique": "^1.2.2",
     "pdf-parse": "^1.1.1",
+    "qrcode": "^1.5.4",
     "zod": "^3.23.8"
   },
   "engines": {

+ 41 - 0
src/controllers/Conversas.Controller.js

@@ -0,0 +1,41 @@
+import { listarConversas, listarMensagens, responderConversa, excluirConversa } from "../services/whatsappService.js";
+
+export const ConversasController = {
+  Listar: async function (req, res, next) {
+    try {
+      const results = await listarConversas();
+      res.json({ results });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  ListarMensagens: async function (req, res, next) {
+    try {
+      const results = await listarMensagens(req.params.id, req.query.after);
+      res.json({ results });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Responder: async function (req, res, next) {
+    try {
+      const mensagem = await responderConversa(req.params.id, req.body.texto);
+      res.json(mensagem);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Excluir: async function (req, res, next) {
+    try {
+      await excluirConversa(req.params.id);
+      res.status(204).send();
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default ConversasController;

+ 31 - 0
src/controllers/WhatsappConexao.Controller.js

@@ -0,0 +1,31 @@
+import { iniciarConexao, obterStatus, desconectar } 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());
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default WhatsappConexaoController;

+ 2 - 0
src/factories/Server.factory.js

@@ -12,6 +12,7 @@ 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";
 
 export class ServerFactory {
   static Iniciar() {
@@ -59,6 +60,7 @@ export class ServerFactory {
       scheduleSyncAtendimentos();
       scheduleAvaliarAtendimentos();
       scheduleIngestAtendimentosRag();
+      iniciarConexaoSalva().catch((e) => console.error("[whatsapp] falha ao reconectar sessão salva:", e.message));
     });
 
     this.app = app;

+ 9 - 0
src/middleware/schemas/Conversas.Schema.js

@@ -0,0 +1,9 @@
+import { z } from "zod";
+
+export const conversaMensagemBodySchema = z.object({
+  texto: z.string().min(1).max(4096)
+});
+
+export const conversaMensagensQuerySchema = z.object({
+  after: z.string().optional()
+});

+ 26 - 0
src/models/WhatsappConversa.model.js

@@ -0,0 +1,26 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+import { WhatsappMensagem } from "./WhatsappMensagem.model.js";
+
+export class WhatsappConversa extends Model {
+  static get tableName() {
+    return "whatsapp_conversas";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      mensagens: {
+        relation: Model.HasManyRelation,
+        modelClass: WhatsappMensagem,
+        join: { from: "whatsapp_conversas.Id", to: "whatsapp_mensagens.ConversaId" }
+      }
+    };
+  }
+}
+
+export default WhatsappConversa;
+

+ 14 - 0
src/models/WhatsappMensagem.model.js

@@ -0,0 +1,14 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+
+export class WhatsappMensagem extends Model {
+  static get tableName() {
+    return "whatsapp_mensagens";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+}
+
+export default WhatsappMensagem;

+ 14 - 0
src/routes/Conversas.Rotas.js

@@ -0,0 +1,14 @@
+import { Router } from "express";
+import { ConversasController } from "../controllers/Conversas.Controller.js";
+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 { conversaMensagemBodySchema, conversaMensagensQuerySchema } from "../middleware/schemas/Conversas.Schema.js";
+
+export const conversasRouter = Router();
+
+conversasRouter.get("/", requireUser, ConversasController.Listar);
+conversasRouter.get("/:id/mensagens", requireUser, parseId, validate({ query: conversaMensagensQuerySchema }), ConversasController.ListarMensagens);
+conversasRouter.post("/:id/mensagens", requireUser, parseId, validate({ body: conversaMensagemBodySchema }), ConversasController.Responder);
+conversasRouter.delete("/:id", requireUser, requireAdmin, parseId, ConversasController.Excluir);

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

@@ -0,0 +1,12 @@
+import { Router } from "express";
+import { WhatsappConexaoController } from "../controllers/WhatsappConexao.Controller.js";
+import { requireUser } from "../middleware/RequireUser.js";
+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;

+ 4 - 0
src/routes/index.js

@@ -9,6 +9,8 @@ import { ingestRouter } from "./Ingest.Rotas.js";
 import { documentsRouter } from "./Documents.Rotas.js";
 import { conversationsRouter } from "./Conversations.Rotas.js";
 import { atendimentosRouter } from "./Atendimentos.Rotas.js";
+import { conversasRouter } from "./Conversas.Rotas.js";
+import { whatsappConexaoRouter } from "./WhatsappConexao.Rotas.js";
 
 export class Roteamento {
   static IniciarRoteamento(app) {
@@ -22,6 +24,8 @@ export class Roteamento {
     api.use("/documents", documentsRouter);
     api.use("/conversations", conversationsRouter);
     api.use("/atendimentos", atendimentosRouter);
+    api.use("/conversas", conversasRouter);
+    api.use("/whatsapp", whatsappConexaoRouter);
 
     app.use("/api", rateLimitMiddleware, authMiddleware, api);
   }

+ 34 - 12
src/services/atendimentosQueryService.js

@@ -26,7 +26,7 @@ async function buscarPorCodigos(codigos) {
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 
-async function buscarPorNomeCliente({ clienteNome, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+async function buscarPorNomeCliente({ clienteNome, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
   const termo = String(clienteNome ?? "").trim();
   if (!termo) return [];
 
@@ -39,6 +39,8 @@ async function buscarPorNomeCliente({ clienteNome, dataInicio, dataFim, limit =
     .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
+  if (setor) query = query.where("Setor", setor);
+  if (status !== null && status !== undefined) query = query.where("Status", status);
   if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
   if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
 
@@ -207,11 +209,30 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, dataInicio, d
   const termo = String(termos ?? "").trim();
   if (!termo) return [];
 
-  let ranqueados = await AtendimentoMensagem.query()
-    .select("AtendimentoId")
-    .select(AtendimentoMensagem.knex().raw("MAX(MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)) as relevancia", [termo]))
-    .whereRaw("MATCH(Body, Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)", [termo])
-    .groupBy("AtendimentoId")
+  const temFiltro = Boolean(setor) || (status !== null && status !== undefined) || Boolean(dataInicio) || Boolean(dataFim);
+
+  function aplicarFiltroAtendimento(q) {
+    if (!temFiltro) return q;
+    q = q.join("atendimentos", "atendimentos.Id", "atendimento_mensagens.AtendimentoId");
+    if (setor) q = q.where("atendimentos.Setor", setor);
+    if (status !== null && status !== undefined) q = q.where("atendimentos.Status", status);
+    if (dataInicio) q = q.where("atendimentos.Abertura", ">=", dataInicio);
+    if (dataFim) q = q.where("atendimentos.Abertura", "<=", `${dataFim} 23:59:59`);
+    return q;
+  }
+
+  let ranqueados = await aplicarFiltroAtendimento(
+    AtendimentoMensagem.query()
+      .select("atendimento_mensagens.AtendimentoId")
+      .select(
+        AtendimentoMensagem.knex().raw(
+          "MAX(MATCH(atendimento_mensagens.Body, atendimento_mensagens.Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)) as relevancia",
+          [termo]
+        )
+      )
+      .whereRaw("MATCH(atendimento_mensagens.Body, atendimento_mensagens.Transcricao) AGAINST(? IN NATURAL LANGUAGE MODE)", [termo])
+      .groupBy("atendimento_mensagens.AtendimentoId")
+  )
     .orderBy("relevancia", "desc")
     .limit(limit);
 
@@ -219,13 +240,14 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, dataInicio, d
     const topScore = Number(ranqueados[0].relevancia);
     ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
   } else {
-    
+
     ranqueados = (
-      await AtendimentoMensagem.query()
-        .select("AtendimentoId")
-        .whereRaw("(Body LIKE ? OR Transcricao LIKE ?)", [`%${termo}%`, `%${termo}%`])
-        .groupBy("AtendimentoId")
-        .limit(limit)
+      await aplicarFiltroAtendimento(
+        AtendimentoMensagem.query()
+          .select("atendimento_mensagens.AtendimentoId")
+          .whereRaw("(atendimento_mensagens.Body LIKE ? OR atendimento_mensagens.Transcricao LIKE ?)", [`%${termo}%`, `%${termo}%`])
+          .groupBy("atendimento_mensagens.AtendimentoId")
+      ).limit(limit)
     ).map((r) => ({ AtendimentoId: r.AtendimentoId }));
   }
 

+ 192 - 0
src/services/whatsappService.js

@@ -0,0 +1,192 @@
+import path from "node:path";
+import fs from "node:fs";
+import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } from "baileys";
+import qrcode from "qrcode";
+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");
+
+let sock = null;
+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) {
+  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() });
+    }
+    return existente.Id;
+  }
+
+  const criada = await WhatsappConversa.query().insert({
+    Telefone: telefone,
+    NomeContato: nomeContato ?? null,
+    Jid: jid ?? null,
+    Status: "aberta"
+  });
+  return criada.Id;
+}
+
+export async function iniciarConexao() {
+  if (sock) return;
+
+  fs.mkdirSync(SESSION_DIR, { recursive: true });
+  const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
+  const { version } = await fetchLatestBaileysVersion();
+
+  sock = makeWASocket({ auth: state, version });
+  estado.status = "conectando";
+
+  sock.ev.on("creds.update", saveCreds);
+
+  sock.ev.on("connection.update", async (update) => {
+    if (update.qr) {
+      estado.qrDataUrl = await qrcode.toDataURL(update.qr);
+      estado.status = "qr_pendente";
+    }
+
+    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 });
+      } 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(() => {});
+      }
+    }
+  });
+
+  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}`);
+        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);
+      const texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? `[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}"`);
+      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
+        })
+        .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() });
+    }
+  });
+}
+
+export function obterStatus() {
+  return estado;
+}
+
+export async function desconectar() {
+  if (sock) {
+    await sock.logout().catch(() => {});
+    sock = null;
+  }
+  fs.rmSync(SESSION_DIR, { recursive: true, force: true });
+  estado = { status: "desconectado", qrDataUrl: null, telefone: null, ultimoErro: null };
+}
+
+export async function enviarMensagem(jid, texto) {
+  if (!sock || estado.status !== "conectado") {
+    throw new BadRequestError("whatsapp_nao_conectado");
+  }
+
+  const res = await sock.sendMessage(jid, { text: texto });
+  return res?.key?.id ?? null;
+}
+
+export async function iniciarConexaoSalva() {
+  if (fs.existsSync(SESSION_DIR) && fs.readdirSync(SESSION_DIR).length > 0) {
+    await iniciarConexao();
+  }
+}
+
+export async function listarConversas() {
+  return WhatsappConversa.query().orderBy("UltimaMensagemEm", "desc").orderBy("UpdatedAt", "desc");
+}
+
+export async function listarMensagens(conversaId, after) {
+  const query = WhatsappMensagem.query().where("ConversaId", conversaId).orderBy("Timestamp", "asc").orderBy("Id", "asc");
+  if (after) query.where("Timestamp", ">", new Date(after));
+  return query;
+}
+
+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 timestamp = new Date();
+
+  const mensagem = await WhatsappMensagem.query().insert({
+    ConversaId: conversaId,
+    Direcao: "saida",
+    Corpo: texto,
+    WhatsappMessageId: whatsappMessageId,
+    Timestamp: timestamp
+  });
+
+  await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
+
+  return mensagem;
+}
+
+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);
+}