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); }