| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- import path from "node:path";
- import fs from "node:fs";
- import { randomUUID } from "node:crypto";
- 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";
- import { createTtlCache } from "./simpleCache.js";
- const MEDIA_DIR = path.resolve("data/whatsapp-media");
- const TIPOS_MIDIA = {
- image: "imagem",
- video: "video",
- audio: "audio",
- sticker: "sticker",
- document: "documento"
- };
- const EXTENSOES_POR_MIMETYPE = {
- "image/jpeg": ".jpg",
- "image/png": ".png",
- "image/webp": ".webp",
- "image/gif": ".gif",
- "video/mp4": ".mp4",
- "video/3gpp": ".3gp",
- "audio/ogg": ".ogg",
- "audio/mpeg": ".mp3",
- "audio/mp4": ".m4a"
- };
- function extensaoPorMimetype(mimetype) {
- if (!mimetype) return "";
- const base = mimetype.split(";")[0].trim();
- return EXTENSOES_POR_MIMETYPE[base] ?? "";
- }
- 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(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 ?? mimetype)}`;
- fs.writeFileSync(path.join(MEDIA_DIR, nomeArquivo), buffer);
- return nomeArquivo;
- }
- 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 (Object.keys(patch).length > 0) {
- await WhatsappConversa.query().findById(existente.Id).patch({ ...patch, UpdatedAt: new Date() });
- }
- return existente.Id;
- }
- try {
- const criada = await WhatsappConversa.query().insert({
- Telefone: telefone,
- NomeContato: nomeContato ?? null,
- Status: "aberta"
- });
- return criada.Id;
- } catch (err) {
-
- 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;
- }
- throw err;
- }
- }
- // telefone e conteúdo de cliente não vão pro log: só o suficiente pra correlacionar
- function mascararTelefone(telefone) {
- const t = String(telefone ?? "");
- return t.length > 8 ? `${t.slice(0, 4)}*****${t.slice(-4)}` : "****";
- }
- export async function processarMensagemWebhook(value) {
- const mensagens = value?.messages ?? [];
- for (const m of mensagens) {
- const telefone = m?.from;
- try {
- const nomeContato = value?.contacts?.find((c) => c.wa_id === telefone)?.profile?.name ?? null;
- let texto = null;
- let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
- if (m.type === "text") {
- texto = m.text?.body ?? "";
- } else {
- 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]";
- }
- }
- const timestamp = m.timestamp ? new Date(Number(m.timestamp) * 1000) : new Date();
- console.log(`[whatsapp] gravando mensagem de ${mascararTelefone(telefone)} (${texto.length} chars)${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;
- }
- await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
- } catch (err) {
-
- console.error(`[whatsapp] falha ao processar mensagem de ${mascararTelefone(telefone)} (id=${m?.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=${mascararTelefone(s.recipient_id)}`);
- }
- }
- }
- function paraFormatoEnvio(telefone) {
- if (/^55\d{10}$/.test(telefone)) {
- return `55${telefone.slice(2, 4)}9${telefone.slice(4)}`;
- }
- return telefone;
- }
- export async function enviarMensagem(telefone, texto) {
- if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
- throw new BadRequestError("whatsapp_nao_conectado");
- }
- 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");
- }
- }
- const STATUS_CACHE_MS = 30_000;
- const statusCache = createTtlCache(STATUS_CACHE_MS);
- const STATUS_CACHE_KEY = "status";
- export async function obterStatus() {
- const cached = statusCache.get(STATUS_CACHE_KEY);
- if (cached) return cached;
- if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
- const valor = { status: "nao_configurado", telefone: null, verifiedName: null, ultimoErro: null };
- statusCache.set(STATUS_CACHE_KEY, valor);
- return valor;
- }
- let valor;
- try {
- const info = await obterInfoNumero();
- valor = {
- status: "conectado",
- telefone: info.display_phone_number ?? null,
- verifiedName: info.verified_name ?? null,
- ultimoErro: null
- };
- } catch (err) {
- valor = {
- status: "erro",
- telefone: null,
- verifiedName: null,
- ultimoErro: err?.response?.data?.error?.message ?? err.message
- };
- }
- statusCache.set(STATUS_CACHE_KEY, valor);
- return valor;
- }
- 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 whatsappMessageId = await enviarMensagem(conversa.Telefone, 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 obterCaminhoMidia(conversaId, mensagemId) {
- const mensagem = await WhatsappMensagem.query().findById(mensagemId);
- if (!mensagem || String(mensagem.ConversaId) !== String(conversaId) || !mensagem.MidiaPath) {
- throw new NotFoundError("midia_not_found");
- }
- return { caminho: path.join(MEDIA_DIR, mensagem.MidiaPath), mimetype: mensagem.MidiaMimetype };
- }
- export async function excluirConversa(conversaId) {
- const conversa = await WhatsappConversa.query().findById(conversaId);
- if (!conversa) throw new NotFoundError("conversa_not_found");
- await WhatsappConversa.query().deleteById(conversaId);
- }
|