Files
reformix-hackaton/mvp/Whatsapp-bot/src/whatsapp/whatsapp.service.ts
Carlos Narro b0871b733c Paso de fotos + flujo cross-canal llamada→WhatsApp para el render
Bot: al cerrar la cualificación, Luisa pide una foto y entra en modo recogida;
al llegar la foto la sube como "antes" con perfilCompleto:true → dispara render +
presupuesto + entrega del PDF. Nuevo webhook /whatsapp-fotos para que, tras una
llamada, Luisa escriba al lead, referencie lo hablado y le pida las fotos
(reutiliza el mismo modo).

App: el webhook de Retell, tras el análisis de la llamada, llama a pedirFotosWhatsapp
(WHATSAPP_FOTOS_WEBHOOK_URL) con el contexto de la reforma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:50:33 +02:00

622 lines
24 KiB
TypeScript

import { EventEmitter } from 'events';
import {
Injectable,
Logger,
OnModuleInit,
OnModuleDestroy,
} from '@nestjs/common';
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
WASocket,
downloadMediaMessage,
normalizeMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import * as path from 'path';
const pino = require('pino');
const QRCode = require('qrcode-terminal');
import { LeadsService } from '../leads/leads.service';
import { ConversacionService } from '../conversacion/conversacion.service';
import { ClaudeService } from '../claude/claude.service';
import { MediaService } from '../media/media.service';
import { WhatsappDebounceService } from './whatsapp-debounce.service';
import { WebhookListener } from '../webhook/webhook-listener';
import { ApiClient } from '../api/api-client.service';
import { wrapSocket } from 'baileys-antiban';
export const pdfEmitter = new EventEmitter();
export const startEmitter = new EventEmitter();
export const fotosEmitter = new EventEmitter();
interface LeadContext {
leadId: string;
telefono: string;
nombre: string;
botStep: string;
viable: boolean | null;
}
@Injectable()
export class WhatsappService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WhatsappService.name);
private sock: WASocket | null = null;
private authDir = process.env.BAILEYS_AUTH_DIR || path.join(process.cwd(), 'auth_info_baileys');
private readonly ultimoMsgPorJid = new Map<string, any>();
private baileysLogger = pino({ level: 'info' });
// leadId por JID
private readonly jidToLeadId = new Map<string, string>();
// contexto de lead por leadId
private readonly leadCache = new Map<string, LeadContext>();
// leads cuya conversación ya se mandó a post-análisis (para no repetir).
private readonly leadsAnalizados = new Set<string>();
// leads a los que se les ha pedido foto y estamos esperándola.
private readonly esperandoFotos = new Set<string>();
// leads cuyo pipeline de render/presupuesto ya se disparó (perfilCompleto), para no repetir.
private readonly pipelineDisparado = new Set<string>();
constructor(
private readonly leadsService: LeadsService,
private readonly conversacionService: ConversacionService,
private readonly claudeService: ClaudeService,
private readonly mediaService: MediaService,
private readonly debounceService: WhatsappDebounceService,
private readonly webhookListener: WebhookListener,
private readonly api: ApiClient,
) {}
async onModuleInit() {
await this.conectar();
this.escucharPdf();
this.escucharStart();
this.escucharFotos();
}
async onModuleDestroy() {
if (this.sock) this.sock.end(undefined);
}
private escucharPdf() {
pdfEmitter.on('pdf', async (payload: { leadId: string; telefono: string; pdfBase64: string; filename: string }) => {
this.logger.log(`[PDF] Recibido para leadId=${payload.leadId}`);
// Buscar JID por teléfono
let jid: string | null = null;
for (const [j, lid] of this.jidToLeadId) {
if (lid === payload.leadId) {
jid = j;
break;
}
}
if (!jid) {
jid = `${payload.telefono}@s.whatsapp.net`;
}
if (!this.sock) return;
try {
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, {
document: Buffer.from(payload.pdfBase64, 'base64'),
mimetype: 'application/pdf',
fileName: payload.filename,
caption: 'Aquí tienes tu presupuesto. Si tienes cualquier duda, estamos aquí.',
});
this.logger.log(`PDF enviado a ${jid}`);
} catch (err: any) {
this.logger.error(`Error enviando PDF a ${jid}: ${err.message}`);
}
});
}
// Apertura proactiva: cuando el funnel dispara /whatsapp-start, Luisa escribe ella el primer
// mensaje (el bot ya no es solo reactivo).
private escucharStart() {
startEmitter.on(
'start',
async (p: { leadId: string; telefono: string; nombre: string; empresa: string }) => {
try {
await this.enviarApertura(p);
} catch (err: any) {
this.logger.error(`[APERTURA] Error: ${err.message}`);
}
},
);
}
private async enviarApertura(p: { leadId: string; telefono: string; nombre: string; empresa: string }) {
if (!this.sock) {
this.logger.warn(`[APERTURA] WhatsApp no conectado; no se envía a ${p.telefono}`);
return;
}
const tel = (p.telefono || '').replace(/\D/g, '');
let jid = `${tel}@s.whatsapp.net`;
try {
const res = await this.sock.onWhatsApp(tel);
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
else if (!res || !res[0]?.exists) this.logger.warn(`[APERTURA] ${tel} no parece estar en WhatsApp`);
} catch {
/* seguimos con el jid por defecto */
}
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
const empresa = p.empresa || 'Reformix';
const apertura =
`¡Hola ${primerNombre}! Soy Luisa, del equipo de ${empresa}. 😊\n\n` +
`Acabas de pedir presupuesto para tu reforma y te ayudo a prepararlo (con un render de cómo ` +
`quedaría incluido). Para empezar, cuéntame: ¿qué espacio quieres reformar? (cocina, baño, salón…)`;
// Contexto para los siguientes mensajes del cliente.
this.jidToLeadId.set(jid, p.leadId);
this.webhookListener.registerJid(tel, jid);
this.leadCache.set(p.leadId, {
leadId: p.leadId,
telefono: tel,
nombre: p.nombre || '',
botStep: 'apertura',
viable: null,
});
await this.enviarMensaje(jid, apertura);
this.logger.log(`[APERTURA] Enviada a ${jid} (lead ${p.leadId})`);
try {
await this.api.actualizarPerfil(p.leadId, { estadoWa: 'enviado', botStep: 'apertura', canalOrigen: 'whatsapp' });
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', apertura, { botStep: 'apertura' });
await this.api.registrarIntento(p.leadId, 'whatsapp', 1, 'exitoso', true);
} catch (err: any) {
this.logger.warn(`[APERTURA] No se pudo persistir en la app: ${err.message}`);
}
}
// Recibe una foto en modo "esperando fotos": la sube como "antes" y marca perfilCompleto, lo que
// dispara en la app la generación de render + presupuesto + entrega del PDF.
private async recibirFotoYFinalizar(ctx: LeadContext, jid: string, msg: any, msgContent: any): Promise<void> {
if (!this.sock || this.pipelineDisparado.has(ctx.leadId)) return;
try {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage?.mimetype || 'image/jpeg';
this.esperandoFotos.delete(ctx.leadId);
this.pipelineDisparado.add(ctx.leadId);
await this.api.enviarIngesta(
ctx.leadId,
[{ tipo: 'foto', imagen: `data:${mimeType};base64,${base64}`, zona: 'otro', momento: 'antes' }],
{ perfilCompleto: true },
);
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', '[foto del espacio]', { botStep: 'fotos_recibidas' });
const conf = '¡Perfecto! Con esto preparo tu presupuesto con el render. En un momento te llega aquí mismo 🛠️';
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', conf, { botStep: 'fotos_recibidas' });
await this.enviarMensaje(jid, conf);
this.logger.log(`[FOTOS] lead ${ctx.leadId}: foto recibida → perfilCompleto disparado`);
} catch (err: any) {
this.pipelineDisparado.delete(ctx.leadId);
this.logger.error(`[FOTOS] error procesando foto de ${ctx.leadId}: ${err.message}`);
}
}
// Cross-canal: tras una llamada, la app pide por webhook que Luisa escriba al lead, referencie lo
// hablado y le pida las fotos. Reutiliza el mismo modo de recogida.
private escucharFotos() {
fotosEmitter.on(
'fotos',
async (p: { leadId: string; telefono: string; nombre: string; empresa?: string; contexto?: string }) => {
try {
await this.iniciarRecogidaFotos(p);
} catch (err: any) {
this.logger.error(`[FOTOS] iniciarRecogida error: ${err.message}`);
}
},
);
}
private async iniciarRecogidaFotos(p: {
leadId: string;
telefono: string;
nombre: string;
empresa?: string;
contexto?: string;
}): Promise<void> {
if (!this.sock) {
this.logger.warn(`[FOTOS] WhatsApp no conectado; no se pide foto a ${p.telefono}`);
return;
}
const jid = await this.resolverJidYRegistrar(p.leadId, p.telefono, p.nombre, 'pide_fotos');
this.esperandoFotos.add(p.leadId);
const primerNombre = (p.nombre || '').trim().split(' ')[0] || 'hola';
const empresa = p.empresa || 'Reformix';
const ctx = p.contexto ? ` sobre ${p.contexto}` : '';
const mensaje =
`¡Hola ${primerNombre}! Soy Luisa, de ${empresa}. 😊 Gracias por tu llamada${ctx}. ` +
`Para terminar tu presupuesto con el render, mándame una foto del espacio 📸`;
await this.conversacionService.guardarMensaje(p.leadId, 'assistant', mensaje, { botStep: 'pide_fotos' });
await this.enviarMensaje(jid, mensaje);
this.logger.log(`[FOTOS] recogida iniciada para lead ${p.leadId} (cross-canal)`);
}
// Resuelve el jid real del teléfono (vía onWhatsApp) y registra el contexto del lead.
private async resolverJidYRegistrar(leadId: string, telefono: string, nombre: string, botStep: string): Promise<string> {
const tel = (telefono || '').replace(/\D/g, '');
let jid = `${tel}@s.whatsapp.net`;
try {
const res = await this.sock?.onWhatsApp(tel);
if (res && res[0]?.exists && res[0]?.jid) jid = res[0].jid;
} catch {
/* jid por defecto */
}
this.jidToLeadId.set(jid, leadId);
this.webhookListener.registerJid(tel, jid);
if (!this.leadCache.has(leadId)) {
this.leadCache.set(leadId, { leadId, telefono: tel, nombre: nombre || '', botStep, viable: null });
}
return jid;
}
private normalizarTelefono(jid: string): string {
return jid.split('@')[0].replace(/\D/g, '');
}
// WhatsApp puede entregar mensajes desde una dirección @lid (id de privacidad) en vez del número.
// Resolvemos el número real vía remoteJidAlt o el mapa LID→PN de Baileys; si no, caemos al jid.
private resolverTelefono(msg: any): string {
const jid: string = msg.key?.remoteJid || '';
if (jid.endsWith('@lid')) {
const alt = msg.key?.remoteJidAlt;
if (typeof alt === 'string' && alt.includes('@s.whatsapp.net')) return this.normalizarTelefono(alt);
try {
const pn = (this.sock as any)?.signalRepository?.lidMapping?.getPNForLID?.(jid);
if (typeof pn === 'string' && pn) return this.normalizarTelefono(pn);
} catch {
/* sin mapping disponible */
}
}
return this.normalizarTelefono(jid);
}
private calcularDelayEscritura(longitudTexto: number): number {
const min = 1500;
const max = 4000;
const factor = Math.min(longitudTexto / 120, 1);
return Math.round(min + (max - min) * factor);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async conectar() {
const { state, saveCreds } = await useMultiFileAuthState(this.authDir);
const { version } = await fetchLatestBaileysVersion();
this.baileysLogger = pino({ level: 'info' }) as any;
this.sock = makeWASocket({
version,
auth: state,
printQRInTerminal: false,
logger: this.baileysLogger,
// true: marca el dispositivo "online" al conectar para que WhatsApp le ENTREGUE los mensajes
// entrantes tras reconectar (con false, al reanudar la sesión quedaba "no disponible" y no
// recibía nada aunque el socket dijera "open").
markOnlineOnConnect: true,
generateHighQualityLinkPreview: false,
syncFullHistory: false,
});
this.sock.ev.on('creds.update', saveCreds);
this.sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect, qr } = update;
this.webhookListener.setConnState({
connection: connection ?? null,
hasQr: !!qr,
lastDisconnect: (lastDisconnect?.error as Boom)?.output?.statusCode ?? null,
at: new Date().toISOString(),
});
if (qr) {
QRCode.generate(qr, { small: true });
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
this.webhookListener.setQr(qr);
}
if (connection === 'close') {
const shouldReconnect =
(lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut;
this.logger.warn(`Conexion cerrada. Reconectar: ${shouldReconnect}.`);
this.webhookListener.setConectado(false);
if (shouldReconnect) setTimeout(() => this.conectar(), 5000);
else this.logger.error('Sesion cerrada (logged out).');
} else if (connection === 'open') {
this.logger.log('✅ WhatsApp conectado. Luisa esta lista.');
this.webhookListener.setConectado(true);
}
});
this.sock.ev.on('messages.upsert', async ({ messages, type }) => {
for (const msg of messages) {
this.webhookListener.pushInbound({
type,
remoteJid: msg.key.remoteJid ?? null,
remoteJidAlt: (msg.key as any).remoteJidAlt ?? null,
fromMe: !!msg.key.fromMe,
msgType: msg.message ? Object.keys(msg.message)[0] : null,
at: new Date().toISOString(),
});
}
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (!msg.key.remoteJid) continue;
if (msg.key.remoteJid.includes('@g.us')) continue;
const telefonoNormalizado = this.normalizarTelefono(msg.key.remoteJid);
const allowedNumber = process.env.ALLOWED_NUMBER?.replace(/\D/g, '');
if (allowedNumber && telefonoNormalizado !== allowedNumber) continue;
await this.encolarMensaje(msg);
}
});
}
private extraerTextoPlano(msg: any): string | null {
const msgContent = msg.message;
if (!msgContent) return null;
if (msgContent.conversation || msgContent.extendedTextMessage) {
const texto = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
return texto.trim() ? texto : null;
}
return null;
}
private crearMsgConTexto(msg: any, texto: string): any {
return { ...msg, message: { conversation: texto } };
}
private async encolarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
const textoPlano = this.extraerTextoPlano(msg);
if (textoPlano === null) {
await this.procesarMensaje(msg);
return;
}
this.ultimoMsgPorJid.set(jid, msg);
await this.debounceService.add(jid, textoPlano, async (combinedMessage) => {
const baseMsg = this.ultimoMsgPorJid.get(jid) ?? msg;
this.ultimoMsgPorJid.delete(jid);
await this.procesarMensaje(this.crearMsgConTexto(baseMsg, combinedMessage));
});
}
private async getOrCreateContext(telefono: string, jid: string): Promise<LeadContext | null> {
let leadId = this.webhookListener.getLeadIdByTelefono(telefono);
// Fallback: si no está en memoria (reinicio del bot), recuperarlo de la BD por teléfono.
if (!leadId) {
leadId = await this.api.buscarLeadPorTelefono(telefono);
if (leadId) {
this.webhookListener.ensureSession(telefono, leadId);
this.logger.log(`Lead ${leadId} recuperado por teléfono ${telefono} (sin sesión en memoria).`);
}
}
this.webhookListener.pushInbound({ stage: 'match', telefono, leadId: leadId ?? null, at: new Date().toISOString() });
if (!leadId) {
this.logger.log(`Mensaje ignorado de ${telefono}: lead no registrado. Debe iniciarse desde la web.`);
return null;
}
this.webhookListener.registerJid(telefono, jid);
this.jidToLeadId.set(jid, leadId);
let ctx = this.leadCache.get(leadId);
if (!ctx) {
const lead = await this.api.getLead(leadId);
ctx = {
leadId,
telefono,
nombre: lead?.nombre || '',
botStep: lead?.botStep || 'nuevo',
viable: lead?.viable ?? null,
};
this.leadCache.set(leadId, ctx);
}
return ctx;
}
private async procesarMensaje(msg: any): Promise<void> {
const jid = msg.key.remoteJid!;
if (jid.includes('@g.us')) return;
const telefono = this.resolverTelefono(msg);
try {
const ctx = await this.getOrCreateContext(telefono, jid);
if (!ctx) return;
const primerMensajeDeUsuario = !this.jidToLeadId.has(jid);
let textoNormalizado = '';
const msgContent = normalizeMessageContent(msg.message);
if (!msgContent) return;
// Modo recogida de fotos (tras cerrar la cualificación o tras una llamada): la foto cierra el
// flujo → sube la foto + dispara render/presupuesto, sin re-cualificar.
if (msgContent.imageMessage && this.esperandoFotos.has(ctx.leadId)) {
await this.recibirFotoYFinalizar(ctx, jid, msg, msgContent);
return;
}
if (msgContent.conversation || msgContent.extendedTextMessage) {
textoNormalizado = msgContent.conversation || msgContent.extendedTextMessage?.text || '';
} else if (msgContent.audioMessage) {
const audioMeta = msgContent.audioMessage;
const mimeType = audioMeta.mimetype || 'audio/ogg; codecs=opus';
this.logger.log(`[AUDIO] Recibido — lead=${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const audioBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
textoNormalizado = await this.mediaService.transcribirAudio(audioBuffer, mimeType);
} else if (msgContent.imageMessage) {
this.logger.log(`Imagen recibida de lead ${ctx.leadId}`);
if (!this.sock) return;
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
textoNormalizado = await this.mediaService.inferirImagen(
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
mimeType,
'en_proceso',
);
if (msgContent.imageMessage.caption) {
textoNormalizado = `${msgContent.imageMessage.caption}\n\n[Contenido de la imagen: ${textoNormalizado}]`;
}
} else {
this.logger.log(`Tipo de mensaje no soportado de lead ${ctx.leadId}. Ignorando.`);
return;
}
if (!textoNormalizado.trim()) return;
this.logger.log(`USUARIO [${telefono}]: ${textoNormalizado}`);
if (primerMensajeDeUsuario) {
await this.api.registrarIntento(ctx.leadId, 'whatsapp', 1, 'exitoso', true);
}
if (msgContent.imageMessage) {
const buffer = await downloadMediaMessage(msg as any, 'buffer', {}, {
logger: this.baileysLogger,
reuploadRequest: this.sock.updateMediaMessage,
});
const base64 = Buffer.isBuffer(buffer) ? buffer.toString('base64') : Buffer.from(buffer).toString('base64');
const mimeType = msgContent.imageMessage.mimetype || 'image/jpeg';
await this.api.enviarIngesta(ctx.leadId, [{
tipo: 'foto',
imagen: `data:${mimeType};base64,${base64}`,
zona: 'otro',
momento: 'antes',
}]);
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'user', textoNormalizado, {
botStep: ctx.botStep,
});
const historial = await this.conversacionService.obtenerHistorialComoMessages(ctx.leadId);
const leadParaClaude = {
id: ctx.leadId,
telefono: ctx.telefono,
nombre: ctx.nombre,
estado_actual: ctx.botStep || 'nuevo',
espacio: null as string | null,
rango_m2: null as string | null,
estilo: null as string | null,
urgencia: null as string | null,
presupuesto_declarado: null as string | null,
viable: ctx.viable as boolean | null,
email: null as string | null,
};
const { respuesta, entidad, viable, nuevoEstado } = await this.claudeService.llamarClaude(
leadParaClaude as any,
historial.slice(0, -1),
textoNormalizado,
);
this.logger.log(`LUISA [${telefono}]: ${respuesta}`);
if ((entidad && Object.keys(entidad).length > 0) || nuevoEstado || viable !== undefined) {
const entidadMap: Record<string, unknown> = {};
if (entidad) {
for (const [k, v] of Object.entries(entidad)) {
const mapped = this.mapearCampoALegacy(k);
entidadMap[mapped] = v;
}
}
await this.leadsService.persistirTurno(ctx.leadId, entidadMap, { nuevoEstado, viable });
if (nuevoEstado) ctx.botStep = nuevoEstado;
if (viable !== undefined) ctx.viable = viable;
this.logger.log(`Lead ${ctx.leadId} persistido — estado=${nuevoEstado || ctx.botStep}`);
}
// ¿Estamos en el cierre? Por estado (errático) O porque Luisa anuncia el presupuesto.
const estadosCierre = ['presupuesto', 'fin_viable', 'fin_no_viable'];
const anunciaPresupuesto =
/presupuesto/i.test(respuesta) &&
/prepar|recib|enseguida|en un momento|te lo env|lo env|aqu[ií] mismo/i.test(respuesta);
const esCierre = estadosCierre.includes(ctx.botStep) || anunciaPresupuesto;
// Al cerrar, dispara el post-análisis de toda la conversación (una sola vez).
if (esCierre && !this.leadsAnalizados.has(ctx.leadId)) {
this.leadsAnalizados.add(ctx.leadId);
this.api
.analizarConversacion(ctx.leadId)
.then((ok) => this.logger.log(`[ANALISIS] lead ${ctx.leadId}: ${ok ? 'ok' : 'fallo'}`))
.catch((e: any) => this.logger.error(`[ANALISIS] ${e.message}`));
}
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', respuesta, {
botStep: ctx.botStep,
});
await this.enviarMensaje(jid, respuesta);
// Tras cerrar, pide una foto para el render (si no la hemos pedido/recibido ya).
if (esCierre && !this.esperandoFotos.has(ctx.leadId) && !this.pipelineDisparado.has(ctx.leadId)) {
this.esperandoFotos.add(ctx.leadId);
const pedir = 'Una última cosa para incluir el render en tu presupuesto: mándame una foto del espacio 📸';
await this.conversacionService.guardarMensaje(ctx.leadId, 'assistant', pedir, { botStep: 'pide_fotos' });
await this.enviarMensaje(jid, pedir);
}
} catch (error: any) {
this.logger.error(`Error procesando mensaje de ${telefono}: ${error.message}`, error.stack);
}
}
private mapearCampoALegacy(campo: string): string {
const map: Record<string, string> = {
espacio: 'espacio',
rango_m2: 'rangoM2',
estilo: 'estilo',
urgencia: 'urgencia',
presupuesto_declarado: 'presupuestoDeclarado',
nombre: 'nombre',
};
return map[campo] || campo;
}
async enviarMensaje(jid: string, texto: string): Promise<void> {
if (!this.sock) return;
try {
const jidPresencia = jid.includes('@lid')
? `${jid.split('@')[0]}@s.whatsapp.net`
: jid;
await this.sock.sendPresenceUpdate('composing', jidPresencia);
await this.delay(this.calcularDelayEscritura(texto.length));
await this.sock.sendPresenceUpdate('paused', jidPresencia);
const safeSock = wrapSocket(this.sock);
await safeSock.sendMessage(jid, { text: texto });
this.logger.log(`Mensaje enviado a ${jid}`);
} catch (error: any) {
this.logger.error(`Error enviando mensaje a ${jid}: ${error.message}`);
}
}
isConectado(): boolean {
return this.sock !== null;
}
}