whatsappService.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import path from "node:path";
  2. import fs from "node:fs";
  3. import { randomUUID } from "node:crypto";
  4. import { config } from "../config/index.js";
  5. import { enviarTexto, obterUrlMidia, baixarBytesMidia, obterInfoNumero } from "./whatsappCloudClient.js";
  6. import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
  7. import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
  8. import { BadRequestError, NotFoundError } from "../shared/errors/index.js";
  9. import { createTtlCache } from "./simpleCache.js";
  10. const MEDIA_DIR = path.resolve("data/whatsapp-media");
  11. const TIPOS_MIDIA = {
  12. image: "imagem",
  13. video: "video",
  14. audio: "audio",
  15. sticker: "sticker",
  16. document: "documento"
  17. };
  18. const EXTENSOES_POR_MIMETYPE = {
  19. "image/jpeg": ".jpg",
  20. "image/png": ".png",
  21. "image/webp": ".webp",
  22. "image/gif": ".gif",
  23. "video/mp4": ".mp4",
  24. "video/3gpp": ".3gp",
  25. "audio/ogg": ".ogg",
  26. "audio/mpeg": ".mp3",
  27. "audio/mp4": ".m4a"
  28. };
  29. function extensaoPorMimetype(mimetype) {
  30. if (!mimetype) return "";
  31. const base = mimetype.split(";")[0].trim();
  32. return EXTENSOES_POR_MIMETYPE[base] ?? "";
  33. }
  34. function detectarMidia(m) {
  35. const tipo = TIPOS_MIDIA[m.type];
  36. if (!tipo) return null;
  37. const parte = m[m.type];
  38. return {
  39. tipo,
  40. mediaId: parte.id,
  41. mimetype: parte.mime_type ?? null,
  42. caption: typeof parte.caption === "string" ? parte.caption.trim() : "",
  43. nomeArquivo: parte.filename ?? null
  44. };
  45. }
  46. async function baixarMidia(midiaInfo) {
  47. const { url } = await obterUrlMidia(midiaInfo.mediaId);
  48. const { buffer, mimetype } = await baixarBytesMidia(url);
  49. fs.mkdirSync(MEDIA_DIR, { recursive: true });
  50. const nomeArquivo = `${randomUUID()}${extensaoPorMimetype(midiaInfo.mimetype ?? mimetype)}`;
  51. fs.writeFileSync(path.join(MEDIA_DIR, nomeArquivo), buffer);
  52. return nomeArquivo;
  53. }
  54. async function obterOuCriarConversa(telefone, nomeContato) {
  55. const existente = await WhatsappConversa.query().findOne({ Telefone: telefone });
  56. if (existente) {
  57. const patch = {};
  58. if (nomeContato && nomeContato !== existente.NomeContato) patch.NomeContato = nomeContato;
  59. if (Object.keys(patch).length > 0) {
  60. await WhatsappConversa.query().findById(existente.Id).patch({ ...patch, UpdatedAt: new Date() });
  61. }
  62. return existente.Id;
  63. }
  64. try {
  65. const criada = await WhatsappConversa.query().insert({
  66. Telefone: telefone,
  67. NomeContato: nomeContato ?? null,
  68. Status: "aberta"
  69. });
  70. return criada.Id;
  71. } catch (err) {
  72. if (err?.code === "ER_DUP_ENTRY" || err?.nativeError?.code === "ER_DUP_ENTRY") {
  73. const criadaPelaCorrida = await WhatsappConversa.query().findOne({ Telefone: telefone });
  74. if (criadaPelaCorrida) return criadaPelaCorrida.Id;
  75. }
  76. throw err;
  77. }
  78. }
  79. // telefone e conteúdo de cliente não vão pro log: só o suficiente pra correlacionar
  80. function mascararTelefone(telefone) {
  81. const t = String(telefone ?? "");
  82. return t.length > 8 ? `${t.slice(0, 4)}*****${t.slice(-4)}` : "****";
  83. }
  84. export async function processarMensagemWebhook(value) {
  85. const mensagens = value?.messages ?? [];
  86. for (const m of mensagens) {
  87. const telefone = m?.from;
  88. try {
  89. const nomeContato = value?.contacts?.find((c) => c.wa_id === telefone)?.profile?.name ?? null;
  90. let texto = null;
  91. let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
  92. if (m.type === "text") {
  93. texto = m.text?.body ?? "";
  94. } else {
  95. const midiaInfo = detectarMidia(m);
  96. if (midiaInfo) {
  97. try {
  98. const nomeArquivo = await baixarMidia(midiaInfo);
  99. midiaCampos = {
  100. TipoMidia: midiaInfo.tipo,
  101. MidiaPath: nomeArquivo,
  102. MidiaMimetype: midiaInfo.mimetype,
  103. MidiaNomeArquivo: midiaInfo.nomeArquivo
  104. };
  105. texto = midiaInfo.caption ?? "";
  106. } catch (err) {
  107. console.error("[whatsapp] falha ao baixar mídia:", err.message);
  108. texto = "[mensagem do tipo não suportada]";
  109. }
  110. } else {
  111. texto = "[mensagem do tipo não suportada]";
  112. }
  113. }
  114. const timestamp = m.timestamp ? new Date(Number(m.timestamp) * 1000) : new Date();
  115. console.log(`[whatsapp] gravando mensagem de ${mascararTelefone(telefone)} (${texto.length} chars)${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
  116. const conversaId = await obterOuCriarConversa(telefone, nomeContato);
  117. const inserted = await WhatsappMensagem.query()
  118. .insert({
  119. ConversaId: conversaId,
  120. Direcao: "entrada",
  121. Corpo: texto,
  122. WhatsappMessageId: m.id ?? null,
  123. Timestamp: timestamp,
  124. ...midiaCampos
  125. })
  126. .onConflict("WhatsappMessageId")
  127. .ignore();
  128. if (!inserted?.Id) {
  129. console.log(`[whatsapp] ignorada: WhatsappMessageId duplicado (${m.id})`);
  130. continue;
  131. }
  132. await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
  133. } catch (err) {
  134. console.error(`[whatsapp] falha ao processar mensagem de ${mascararTelefone(telefone)} (id=${m?.id}):`, err);
  135. }
  136. }
  137. if (Array.isArray(value?.statuses)) {
  138. for (const s of value.statuses) {
  139. console.log(`[whatsapp] status de entrega recebido: id=${s.id} status=${s.status} destinatario=${mascararTelefone(s.recipient_id)}`);
  140. }
  141. }
  142. }
  143. function paraFormatoEnvio(telefone) {
  144. if (/^55\d{10}$/.test(telefone)) {
  145. return `55${telefone.slice(2, 4)}9${telefone.slice(4)}`;
  146. }
  147. return telefone;
  148. }
  149. export async function enviarMensagem(telefone, texto) {
  150. if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
  151. throw new BadRequestError("whatsapp_nao_conectado");
  152. }
  153. try {
  154. const data = await enviarTexto(paraFormatoEnvio(telefone), texto);
  155. return data?.messages?.[0]?.id ?? null;
  156. } catch (err) {
  157. const erroMeta = err?.response?.data?.error;
  158. if (erroMeta?.code === 131047 || erroMeta?.code === 131026) {
  159. throw new BadRequestError(
  160. "Mais de 24h desde a última mensagem do cliente — só é possível responder com um modelo (template) aprovado pela Meta."
  161. );
  162. }
  163. throw new BadRequestError(erroMeta?.message ?? "Falha ao enviar mensagem via WhatsApp");
  164. }
  165. }
  166. const STATUS_CACHE_MS = 30_000;
  167. const statusCache = createTtlCache(STATUS_CACHE_MS);
  168. const STATUS_CACHE_KEY = "status";
  169. export async function obterStatus() {
  170. const cached = statusCache.get(STATUS_CACHE_KEY);
  171. if (cached) return cached;
  172. if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) {
  173. const valor = { status: "nao_configurado", telefone: null, verifiedName: null, ultimoErro: null };
  174. statusCache.set(STATUS_CACHE_KEY, valor);
  175. return valor;
  176. }
  177. let valor;
  178. try {
  179. const info = await obterInfoNumero();
  180. valor = {
  181. status: "conectado",
  182. telefone: info.display_phone_number ?? null,
  183. verifiedName: info.verified_name ?? null,
  184. ultimoErro: null
  185. };
  186. } catch (err) {
  187. valor = {
  188. status: "erro",
  189. telefone: null,
  190. verifiedName: null,
  191. ultimoErro: err?.response?.data?.error?.message ?? err.message
  192. };
  193. }
  194. statusCache.set(STATUS_CACHE_KEY, valor);
  195. return valor;
  196. }
  197. export async function listarConversas() {
  198. return WhatsappConversa.query().orderBy("UltimaMensagemEm", "desc").orderBy("UpdatedAt", "desc");
  199. }
  200. export async function listarMensagens(conversaId, after) {
  201. const query = WhatsappMensagem.query().where("ConversaId", conversaId).orderBy("Timestamp", "asc").orderBy("Id", "asc");
  202. if (after) query.where("Timestamp", ">", new Date(after));
  203. return query;
  204. }
  205. export async function responderConversa(conversaId, texto) {
  206. const conversa = await WhatsappConversa.query().findById(conversaId);
  207. if (!conversa) throw new NotFoundError("conversa_not_found");
  208. const whatsappMessageId = await enviarMensagem(conversa.Telefone, texto);
  209. const timestamp = new Date();
  210. const mensagem = await WhatsappMensagem.query().insert({
  211. ConversaId: conversaId,
  212. Direcao: "saida",
  213. Corpo: texto,
  214. WhatsappMessageId: whatsappMessageId,
  215. Timestamp: timestamp
  216. });
  217. await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
  218. return mensagem;
  219. }
  220. export async function obterCaminhoMidia(conversaId, mensagemId) {
  221. const mensagem = await WhatsappMensagem.query().findById(mensagemId);
  222. if (!mensagem || String(mensagem.ConversaId) !== String(conversaId) || !mensagem.MidiaPath) {
  223. throw new NotFoundError("midia_not_found");
  224. }
  225. return { caminho: path.join(MEDIA_DIR, mensagem.MidiaPath), mimetype: mensagem.MidiaMimetype };
  226. }
  227. export async function excluirConversa(conversaId) {
  228. const conversa = await WhatsappConversa.query().findById(conversaId);
  229. if (!conversa) throw new NotFoundError("conversa_not_found");
  230. await WhatsappConversa.query().deleteById(conversaId);
  231. }