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