瀏覽代碼

comeco integra msg ifbot

GabrielRamison 2 月之前
父節點
當前提交
9580a4a404

+ 5 - 0
.env.example

@@ -76,3 +76,8 @@ RAG_RERANK_TOP_N=8
 # de similaridade porque vira matching passagem-passagem. Custa uma chamada LLM extra por
 # pergunta. Validar com backend/scripts/evalRetrieval.mjs antes de habilitar em produção.
 RAG_HYDE=false
+
+# Chatbot ifbot (sincronização de atendimentos)
+IFBOT_BASE_URL=https://star.ifbot.com.br/api
+IFBOT_TOKEN=
+# IFBOT_AUTH_HEADER=Authorization

+ 55 - 0
db/migrations/20260708120000_create_atendimentos_tables.cjs

@@ -0,0 +1,55 @@
+exports.up = function (knex) {
+  return knex.schema
+    .createTable("atendimento_clientes", (t) => {
+      t.integer("Id").unsigned().primary(); // Id do cliente no chatbot (ifbot)
+      t.string("Nome", 255).notNullable();
+      t.string("Foto", 500).nullable();
+      t.string("Wid", 30).nullable();
+      t.string("Ultima2", 255).nullable();
+      t.string("Ultimacliente2", 255).nullable();
+      t.boolean("Deadmensage2").nullable();
+      t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+      t.timestamp("UpdatedAt").notNullable().defaultTo(knex.fn.now());
+
+      t.index(["Wid"]);
+    })
+    .createTable("atendimentos", (t) => {
+      t.integer("Id").unsigned().primary(); // Id do protocolo no chatbot (ifbot)
+      t.string("Codigo", 50).notNullable();
+      t.integer("Status").notNullable();
+      t.integer("ClienteId").unsigned().notNullable();
+      t.string("Setor", 100).nullable();
+      t.string("SourceUrl", 500).nullable();
+      t.timestamp("IngestedAt").notNullable().defaultTo(knex.fn.now());
+      t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+      t.timestamp("UpdatedAt").notNullable().defaultTo(knex.fn.now());
+
+      t.foreign("ClienteId").references("atendimento_clientes.Id").onDelete("CASCADE");
+      t.unique(["Codigo"]);
+      t.index(["ClienteId"]);
+      t.index(["Setor"]);
+      t.index(["Status"]);
+    })
+    .createTable("atendimento_mensagens", (t) => {
+      t.bigInteger("Id").unsigned().primary(); // Id da mensagem no chatbot (ifbot)
+      t.integer("AtendimentoId").unsigned().notNullable();
+      t.text("Body").nullable();
+      t.integer("Resposta").nullable();
+      t.datetime("Timestamp").nullable();
+      t.string("Tipodemidia", 30).nullable();
+      t.string("Midia", 500).nullable();
+      t.string("Autor", 255).nullable();
+      t.text("Citacao").nullable();
+
+      t.foreign("AtendimentoId").references("atendimentos.Id").onDelete("CASCADE");
+      t.index(["AtendimentoId"]);
+      t.index(["Timestamp"]);
+    });
+};
+
+exports.down = function (knex) {
+  return knex.schema
+    .dropTable("atendimento_mensagens")
+    .dropTable("atendimentos")
+    .dropTable("atendimento_clientes");
+};

+ 19 - 0
db/migrations/20260708130000_add_atendimento_sync_fields.cjs

@@ -0,0 +1,19 @@
+exports.up = function (knex) {
+  return knex.schema
+    .alterTable("atendimento_clientes", (t) => {
+      t.string("Telefone", 30).nullable();
+    })
+    .alterTable("atendimentos", (t) => {
+      t.datetime("Abertura").nullable();
+    });
+};
+
+exports.down = function (knex) {
+  return knex.schema
+    .alterTable("atendimento_clientes", (t) => {
+      t.dropColumn("Telefone");
+    })
+    .alterTable("atendimentos", (t) => {
+      t.dropColumn("Abertura");
+    });
+};

+ 5 - 0
src/config/index.js

@@ -68,6 +68,11 @@ export const config = {
   },
   rateLimit: {
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)
+  },
+  ifbot: {
+    baseUrl: process.env.IFBOT_BASE_URL ?? "https://star.ifbot.com.br/api",
+    token: process.env.IFBOT_TOKEN ?? "",
+    authHeader: process.env.IFBOT_AUTH_HEADER ?? "Authorization"
   }
 };
 

+ 71 - 0
src/controllers/Atendimentos.Controller.js

@@ -0,0 +1,71 @@
+import { NotFoundError } from "../shared/errors/index.js";
+import {
+  fetchAtendimentoJson,
+  salvarAtendimento,
+  sincronizarProtocolosAbertos,
+  listarAtendimentos,
+  obterAtendimento
+} from "../services/atendimentosService.js";
+
+export const AtendimentosController = {
+  Ingerir: async function (req, res, next) {
+    try {
+      const { atendimento, setor } = req.body;
+      const result = await salvarAtendimento({ payload: atendimento, setor });
+      res.json({ ok: true, ...result });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  IngerirUrl: async function (req, res, next) {
+    try {
+      const { url, setor } = req.body;
+      const payload = await fetchAtendimentoJson(url);
+      const result = await salvarAtendimento({ payload, setor, sourceUrl: url });
+      res.json({ ok: true, ...result });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Sincronizar: async function (req, res, next) {
+    try {
+      const result = await sincronizarProtocolosAbertos(req.body);
+      res.json({ ok: true, ...result });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Listar: async function (req, res, next) {
+    try {
+      const page = Math.max(1, Number(req.query.page) || 1);
+      const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 20));
+      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
+      const status = req.query.status !== undefined ? Number(req.query.status) : undefined;
+
+      const result = await listarAtendimentos({
+        page,
+        pageSize,
+        setor,
+        status: Number.isInteger(status) ? status : undefined
+      });
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  Obter: async function (req, res, next) {
+    try {
+      const atendimento = await obterAtendimento(req.params.id);
+      if (!atendimento) throw new NotFoundError("atendimento_not_found");
+      res.json(atendimento);
+    } catch (err) {
+      next(err);
+    }
+  }
+};
+
+export default AtendimentosController;

+ 75 - 0
src/middleware/schemas/Atendimento.Schema.js

@@ -0,0 +1,75 @@
+import { z } from "zod";
+
+const protocoloSchema = z.object({
+  Id: z.number().int().positive(),
+  Codigo: z.string().min(1),
+  Status: z.number().int()
+});
+
+const clienteSchema = z.object({
+  Id: z.number().int().positive(),
+  Nome: z.string().min(1),
+  Foto: z.string().nullish(),
+  Wid: z.string().nullish(),
+  Ultima2: z.string().nullish(),
+  Ultimacliente2: z.string().nullish(),
+  Deadmensage2: z.boolean().nullish()
+}).passthrough();
+
+const mensagemSchema = z.object({
+  Id: z.number().int().positive(),
+  Body: z.string().nullish(),
+  Resposta: z.number().int().nullish(),
+  Timestamp: z.string().datetime({ offset: true }).nullish(),
+  Tipodemidia: z.string().nullish(),
+  Midia: z.string().nullish(),
+  Autor: z.string().nullish(),
+  Citacao: z.string().nullish()
+}).passthrough();
+
+export const atendimentoPayloadSchema = z.object({
+  status: z.boolean().optional(),
+  Protocolo: protocoloSchema,
+  Cliente: clienteSchema,
+  Mensagens: z.array(mensagemSchema).default([])
+}).passthrough();
+
+export const atendimentoBodySchema = z.object({
+  setor: z.string().min(1).max(100).optional(),
+  atendimento: atendimentoPayloadSchema
+});
+
+export const atendimentoUrlSchema = z.object({
+  url: z.string().url().startsWith("https://"),
+  setor: z.string().min(1).max(100).optional()
+});
+
+export const protocolosAbertosSchema = z.object({
+  status: z.boolean().optional(),
+  Total: z.number().int().optional(),
+  Protocolos: z.array(
+    z.object({
+      Id: z.number().int().positive(),
+      Codigo: z.string().min(1),
+      Abertura: z.string().datetime({ offset: true }).nullish(),
+      Cliente: z.object({
+        Id: z.number().int().positive(),
+        Nome: z.string().min(1),
+        Telefone: z.string().nullish(),
+        Wid: z.string().nullish()
+      }).passthrough()
+    }).passthrough()
+  )
+}).passthrough();
+
+export const protocoloMensagensSchema = z.object({
+  status: z.boolean().optional(),
+  Mensagens: z.array(mensagemSchema).default([])
+}).passthrough();
+
+export const atendimentoSyncSchema = z.object({
+  pagina: z.number().int().positive().optional(),
+  limite: z.number().int().positive().max(200).optional(),
+  inicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+  fim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
+});

+ 31 - 0
src/models/Atendimento.model.js

@@ -0,0 +1,31 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+import { AtendimentoCliente } from "./AtendimentoCliente.model.js";
+import { AtendimentoMensagem } from "./AtendimentoMensagem.model.js";
+
+export class Atendimento extends Model {
+  static get tableName() {
+    return "atendimentos";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      cliente: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: AtendimentoCliente,
+        join: { from: "atendimentos.ClienteId", to: "atendimento_clientes.Id" }
+      },
+      mensagens: {
+        relation: Model.HasManyRelation,
+        modelClass: AtendimentoMensagem,
+        join: { from: "atendimentos.Id", to: "atendimento_mensagens.AtendimentoId" }
+      }
+    };
+  }
+}
+
+export default Atendimento;

+ 25 - 0
src/models/AtendimentoCliente.model.js

@@ -0,0 +1,25 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+import { Atendimento } from "./Atendimento.model.js";
+
+export class AtendimentoCliente extends Model {
+  static get tableName() {
+    return "atendimento_clientes";
+  }
+
+  static get idColumn() {
+    return "Id";
+  }
+
+  static get relationMappings() {
+    return {
+      atendimentos: {
+        relation: Model.HasManyRelation,
+        modelClass: Atendimento,
+        join: { from: "atendimento_clientes.Id", to: "atendimentos.ClienteId" }
+      }
+    };
+  }
+}
+
+export default AtendimentoCliente;

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

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

+ 15 - 0
src/routes/Atendimentos.Rotas.js

@@ -0,0 +1,15 @@
+import { Router } from "express";
+import { AtendimentosController } from "../controllers/Atendimentos.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 { atendimentoBodySchema, atendimentoUrlSchema, atendimentoSyncSchema } from "../middleware/schemas/Atendimento.Schema.js";
+
+export const atendimentosRouter = Router();
+
+atendimentosRouter.post("/", requireUser, requireAdmin, validate({ body: atendimentoBodySchema }), AtendimentosController.Ingerir);
+atendimentosRouter.post("/url", requireUser, requireAdmin, validate({ body: atendimentoUrlSchema }), AtendimentosController.IngerirUrl);
+atendimentosRouter.post("/sync", requireUser, requireAdmin, validate({ body: atendimentoSyncSchema }), AtendimentosController.Sincronizar);
+atendimentosRouter.get("/", requireUser, AtendimentosController.Listar);
+atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);

+ 2 - 0
src/routes/index.js

@@ -8,6 +8,7 @@ import { searchRouter } from "./Search.Rotas.js";
 import { ingestRouter } from "./Ingest.Rotas.js";
 import { documentsRouter } from "./Documents.Rotas.js";
 import { conversationsRouter } from "./Conversations.Rotas.js";
+import { atendimentosRouter } from "./Atendimentos.Rotas.js";
 
 export class Roteamento {
   static IniciarRoteamento(app) {
@@ -20,6 +21,7 @@ export class Roteamento {
     api.use("/ingest", ingestRouter);
     api.use("/documents", documentsRouter);
     api.use("/conversations", conversationsRouter);
+    api.use("/atendimentos", atendimentosRouter);
 
     app.use("/api", rateLimitMiddleware, authMiddleware, api);
   }

+ 259 - 0
src/services/atendimentosService.js

@@ -0,0 +1,259 @@
+import { Model } from "objection";
+import { config } from "../config/index.js";
+import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
+import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
+import {
+  atendimentoPayloadSchema,
+  protocolosAbertosSchema,
+  protocoloMensagensSchema
+} from "../middleware/schemas/Atendimento.Schema.js";
+import { assertPublicHttpsUrl } from "./ingestService.js";
+import { BadRequestError } from "../shared/errors/index.js";
+
+const MAX_REDIRECTS = 5;
+const MENSAGENS_BATCH = 500;
+const SYNC_CONCURRENCY = 4;
+
+function ifbotAuthHeaders() {
+  const { token, authHeader } = config.ifbot;
+  if (!token) return {};
+  const value = authHeader === "Authorization" && !token.includes(" ") ? `Bearer ${token}` : token;
+  return { [authHeader]: value };
+}
+
+async function fetchJson(urlStr, extraHeaders = {}) {
+  let currentUrl = urlStr;
+  let res;
+  for (let hop = 0; ; hop += 1) {
+    assertPublicHttpsUrl(currentUrl);
+
+    res = await fetch(currentUrl, {
+      headers: {
+        "User-Agent": "Mozilla/5.0 star-oraculo/1.0",
+        Accept: "application/json",
+        ...extraHeaders
+      },
+      redirect: "manual",
+      signal: AbortSignal.timeout(15_000)
+    });
+
+    if (res.status < 300 || res.status >= 400) break;
+
+    const location = res.headers.get("location");
+    if (!location || hop >= MAX_REDIRECTS) {
+      const err = new Error(`url_fetch_error:${res.status}`);
+      err.statusCode = 502;
+      throw err;
+    }
+    currentUrl = new URL(location, currentUrl).toString();
+  }
+
+  if (!res.ok) {
+    const err = new Error(`url_fetch_error:${res.status}`);
+    err.statusCode = 502;
+    throw err;
+  }
+
+  try {
+    return await res.json();
+  } catch {
+    const err = new Error("url_invalid_json");
+    err.statusCode = 502;
+    throw err;
+  }
+}
+
+export async function fetchAtendimentoJson(urlStr) {
+  const json = await fetchJson(urlStr);
+  const parsed = atendimentoPayloadSchema.safeParse(json);
+  if (!parsed.success) {
+    throw new BadRequestError("invalid_atendimento_payload");
+  }
+  return parsed.data;
+}
+
+// prefixo do código do protocolo identifica o setor (ex.: SUP0000014690/2026 → SUP)
+export function setorFromCodigo(codigo) {
+  const m = /^([A-Z]{3})\d/.exec(String(codigo ?? ""));
+  return m ? m[1] : null;
+}
+
+function toDate(value) {
+  if (!value) return null;
+  const d = new Date(value);
+  return Number.isNaN(d.getTime()) ? null : d;
+}
+
+export async function salvarAtendimento({ payload, setor, sourceUrl, abertura, telefone }) {
+  const now = new Date();
+  const trx = await Model.startTransaction();
+
+  try {
+    const c = payload.Cliente;
+    const clienteMerge = ["Nome", "Foto", "Wid", "Ultima2", "Ultimacliente2", "Deadmensage2", "UpdatedAt"];
+    const clienteTelefone = telefone ?? c.Telefone ?? null;
+    if (clienteTelefone) clienteMerge.push("Telefone");
+
+    await AtendimentoCliente.query(trx)
+      .insert({
+        Id: c.Id,
+        Nome: c.Nome,
+        Foto: c.Foto ?? null,
+        Wid: c.Wid ?? null,
+        Telefone: clienteTelefone,
+        Ultima2: c.Ultima2 ?? null,
+        Ultimacliente2: c.Ultimacliente2 ?? null,
+        Deadmensage2: c.Deadmensage2 ?? null,
+        UpdatedAt: now
+      })
+      .onConflict("Id")
+      .merge(clienteMerge);
+
+    const p = payload.Protocolo;
+    const atendimentoMerge = ["Codigo", "Status", "ClienteId", "IngestedAt", "UpdatedAt"];
+    if (setor) atendimentoMerge.push("Setor");
+    if (sourceUrl) atendimentoMerge.push("SourceUrl");
+    if (abertura) atendimentoMerge.push("Abertura");
+
+    await Atendimento.query(trx)
+      .insert({
+        Id: p.Id,
+        Codigo: p.Codigo,
+        Status: p.Status,
+        ClienteId: c.Id,
+        Setor: setor ?? null,
+        SourceUrl: sourceUrl ?? null,
+        Abertura: toDate(abertura),
+        IngestedAt: now,
+        UpdatedAt: now
+      })
+      .onConflict("Id")
+      .merge(atendimentoMerge);
+
+    const mensagens = (payload.Mensagens ?? []).map((m) => ({
+      Id: m.Id,
+      AtendimentoId: p.Id,
+      Body: m.Body ?? null,
+      Resposta: m.Resposta ?? null,
+      Timestamp: toDate(m.Timestamp),
+      Tipodemidia: m.Tipodemidia ?? null,
+      Midia: m.Midia ?? null,
+      Autor: m.Autor ?? null,
+      Citacao: m.Citacao ?? null
+    }));
+
+    // batch insert via knex: o Objection não suporta insert em lote no MySQL
+    for (let i = 0; i < mensagens.length; i += MENSAGENS_BATCH) {
+      await trx(AtendimentoMensagem.tableName)
+        .insert(mensagens.slice(i, i + MENSAGENS_BATCH))
+        .onConflict("Id")
+        .merge(["AtendimentoId", "Body", "Resposta", "Timestamp", "Tipodemidia", "Midia", "Autor", "Citacao"]);
+    }
+
+    await trx.commit();
+
+    return {
+      protocoloId: p.Id,
+      codigo: p.Codigo,
+      clienteId: c.Id,
+      mensagens: mensagens.length
+    };
+  } catch (err) {
+    await trx.rollback();
+    throw err;
+  }
+}
+
+async function importarProtocolo(item, baseUrl) {
+  const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
+  const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
+
+  // resposta completa (Protocolo + Cliente + Mensagens) ou só a lista de mensagens
+  let payload;
+  const full = atendimentoPayloadSchema.safeParse(json);
+  if (full.success) {
+    payload = full.data;
+  } else {
+    const soMensagens = protocoloMensagensSchema.safeParse(json);
+    if (!soMensagens.success) throw new BadRequestError("invalid_mensagens_payload");
+    payload = {
+      Protocolo: { Id: item.Id, Codigo: item.Codigo, Status: 1 },
+      Cliente: item.Cliente,
+      Mensagens: soMensagens.data.Mensagens
+    };
+  }
+
+  return salvarAtendimento({
+    payload,
+    setor: setorFromCodigo(item.Codigo),
+    sourceUrl: mensagensUrl,
+    abertura: item.Abertura ?? null,
+    telefone: item.Cliente?.Telefone ?? null
+  });
+}
+
+export async function sincronizarProtocolosAbertos({ pagina = 1, limite = 50, inicio, fim } = {}) {
+  const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
+
+  const params = new URLSearchParams({ pagina: String(pagina), limite: String(limite) });
+  if (inicio) params.set("inicio", inicio);
+  if (fim) params.set("fim", fim);
+
+  const listaJson = await fetchJson(`${baseUrl}/protocolos/abertos?${params.toString()}`, ifbotAuthHeaders());
+  const lista = protocolosAbertosSchema.safeParse(listaJson);
+  if (!lista.success) throw new BadRequestError("invalid_protocolos_payload");
+
+  const protocolos = lista.data.Protocolos;
+  const erros = [];
+  let importados = 0;
+  let mensagens = 0;
+
+  for (let i = 0; i < protocolos.length; i += SYNC_CONCURRENCY) {
+    const chunk = protocolos.slice(i, i + SYNC_CONCURRENCY);
+    const resultados = await Promise.allSettled(chunk.map((item) => importarProtocolo(item, baseUrl)));
+
+    resultados.forEach((r, idx) => {
+      if (r.status === "fulfilled") {
+        importados += 1;
+        mensagens += r.value.mensagens;
+      } else {
+        erros.push({
+          protocoloId: chunk[idx].Id,
+          codigo: chunk[idx].Codigo,
+          erro: r.reason?.message ?? "erro_desconhecido"
+        });
+      }
+    });
+  }
+
+  return {
+    total: lista.data.Total ?? protocolos.length,
+    pagina,
+    limite,
+    processados: protocolos.length,
+    importados,
+    mensagens,
+    erros
+  };
+}
+
+export async function listarAtendimentos({ page = 1, pageSize = 20, setor, status } = {}) {
+  const query = Atendimento.query()
+    .withGraphFetched("cliente")
+    .orderBy("IngestedAt", "desc")
+    .page(page - 1, pageSize);
+
+  if (setor) query.where("Setor", setor);
+  if (status !== undefined) query.where("Status", status);
+
+  const { results, total } = await query;
+  return { results, total, page, pageSize };
+}
+
+export async function obterAtendimento(id) {
+  return Atendimento.query()
+    .findById(id)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+}

+ 1 - 1
src/services/ingestService.js

@@ -130,7 +130,7 @@ function isPrivateUrl(urlStr) {
   }
 }
 
-function assertPublicHttpsUrl(urlStr) {
+export function assertPublicHttpsUrl(urlStr) {
   if (!urlStr.startsWith("https://")) {
     const err = new Error("url_must_be_https");
     err.statusCode = 400;