From 9fa789280f7b49325ea94c0738c21df1c303a275 Mon Sep 17 00:00:00 2001 From: Adriano Rodrigues Date: Tue, 23 Jun 2026 20:16:58 -0300 Subject: [PATCH 1/3] feat: add presence interval to keep notifications active and add docker-compose.prod.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry-pick de 98b7095d, resolvido sobre maxpantech/develop: mantém o approach Multi-Device nativo do HEAD + markOnlineOnConnect:false + stopPresenceInterval no logout/close) --- docker-compose.prod.yaml | 41 +++++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 46 ++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 docker-compose.prod.yaml diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 0000000000..c6ef1bbd5c --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,41 @@ +services: + api: + container_name: lab_evolution_api + image: evolution-api-local + restart: always + ports: + - 8080:8080 + volumes: + - lab_evolution_instances:/lab_evolution/instances + networks: + - lab_evolution + env_file: + - .env + + postgres: + image: postgres:13 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pg_data:/var/lib/postgresql/data + networks: + - lab_evolution + + redis: + image: redis:alpine + restart: always + ports: + - "6380:6379" + networks: + - lab_evolution + +volumes: + lab_evolution_instances: + pg_data: + +networks: + lab_evolution: + driver: bridge diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index e042629994..2d7cd3ed5b 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -271,6 +271,7 @@ export class BaileysStartupService extends ChannelStartupService { private historySyncChatCount = 0; private historySyncContactCount = 0; private historySyncLastProgress = -1; + private presenceInterval: NodeJS.Timeout | null = null; // Cache TTL constants (in seconds) private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing @@ -292,6 +293,7 @@ export class BaileysStartupService extends ChannelStartupService { } public async logoutInstance() { + this.stopPresenceInterval(); // Mark instance as deleting to prevent reconnection attempts. this.isDeleting = true; this.endSession = true; @@ -494,6 +496,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (connection === 'close') { + this.stopPresenceInterval(); // Check if instance is being deleted or session is ending if (this.isDeleting || this.endSession) { this.logger.info('Instance is being deleted/ended, skipping reconnection attempt'); @@ -597,6 +600,10 @@ export class BaileysStartupService extends ChannelStartupService { `, ); + // Força presença como unavailable periodicamente para evitar mute das notificações + // A presença expira após ~10 segundos, então precisamos renovar periodicamente + this.startPresenceInterval(); + await this.prismaRepository.instance.update({ where: { id: this.instanceId }, data: { @@ -630,6 +637,42 @@ export class BaileysStartupService extends ChannelStartupService { } } + private startPresenceInterval() { + // Limpa interval anterior se existir + this.stopPresenceInterval(); + + // Envia presença unavailable imediatamente após 3 segundos (aguarda nome estar disponível) + setTimeout(async () => { + await this.sendUnavailablePresence(); + }, 3000); + + // Renova a presença a cada 5 segundos (expira em ~10s) + this.presenceInterval = setInterval(async () => { + await this.sendUnavailablePresence(); + }, 5000); + + this.logger.info('Presence interval started - keeping device notifications active'); + } + + private stopPresenceInterval() { + if (this.presenceInterval) { + clearInterval(this.presenceInterval); + this.presenceInterval = null; + this.logger.info('Presence interval stopped'); + } + } + + private async sendUnavailablePresence() { + try { + // Só envia unavailable se alwaysOnline estiver desabilitado + if (this.client && this.stateConnection.state === 'open' && !this.localSettings.alwaysOnline) { + await this.client.sendPresenceUpdate('unavailable'); + } + } catch (error) { + this.logger.warn('Erro ao definir presença como unavailable: ' + error?.message); + } + } + private async getMessage(key: proto.IMessageKey, full = false) { try { const provider = this.configService.get('DATABASE').PROVIDER; @@ -770,7 +813,8 @@ export class BaileysStartupService extends ChannelStartupService { generateHighQualityLinkPreview: true, getMessage: async (key) => (await this.getMessage(key)) as Promise, // Removido browserOptions para usar Multi-Device nativo (não WebClient) - markOnlineOnConnect: this.localSettings.alwaysOnline, + // markOnlineOnConnect:false mantém o push das notificações no celular do dono + markOnlineOnConnect: false, retryRequestDelayMs: 350, maxMsgRetryCount: 4, fireInitQueries: true, From 32c9cd83a96e01f450ae550b10a19f1a08e11f5f Mon Sep 17 00:00:00 2001 From: Adriano Rodrigues Date: Tue, 10 Mar 2026 17:03:42 -0300 Subject: [PATCH 2/3] feat: add force delete support for instance deletion Allow bypassing cleanup errors during instance deletion with ?force=true query param, ensuring the database record is always removed even when logout or session cleanup fails. --- docker-compose.prod.yaml | 11 +++--- src/api/controllers/instance.controller.ts | 34 +++++++++++++++++- src/api/dto/instance.dto.ts | 1 + src/api/services/monitor.service.ts | 41 ++++++++++++++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index c6ef1bbd5c..e7db8f5acd 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -1,8 +1,11 @@ services: api: container_name: lab_evolution_api - image: evolution-api-local - restart: always + build: + context: . + dockerfile: Dockerfile + image: adrianoftz1999/evolution-api:latest + platform: linux/amd64 ports: - 8080:8080 volumes: @@ -14,7 +17,6 @@ services: postgres: image: postgres:13 - restart: always environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -26,9 +28,8 @@ services: redis: image: redis:alpine - restart: always ports: - - "6380:6379" + - '6380:6379' networks: - lab_evolution diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 4d468059b0..4c93d17c87 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -457,7 +457,39 @@ export class InstanceController { } } - public async deleteInstance({ instanceName }: InstanceDto) { + public async deleteInstance({ instanceName, force }: InstanceDto) { + const isForce = force === true || force === 'true'; + + if (isForce) { + this.logger.error(`FORCE DELETE instance "${instanceName}"`); + + try { + await this.waMonitor.waInstances[instanceName]?.logoutInstance(); + } catch (error) { + this.logger.error(error); + } + + try { + if (this.configService.get('CHATWOOT').ENABLED) { + this.waMonitor.waInstances[instanceName]?.clearCacheChatwoot(); + } + } catch (error) { + this.logger.error(error); + } + + try { + this.waMonitor.waInstances[instanceName]?.sendDataWebhook(Events.INSTANCE_DELETE, { + instanceName, + instanceId: this.waMonitor.waInstances[instanceName]?.instanceId, + }); + } catch (error) { + this.logger.error(error); + } + + await this.waMonitor.forceDeleteInstance(instanceName); + return { status: 'SUCCESS', error: false, response: { message: 'Instance force deleted' } }; + } + const { instance } = await this.connectionState({ instanceName }); try { const waInstances = this.waMonitor.waInstances[instanceName]; diff --git a/src/api/dto/instance.dto.ts b/src/api/dto/instance.dto.ts index 85c5b69c33..963ecc56ba 100644 --- a/src/api/dto/instance.dto.ts +++ b/src/api/dto/instance.dto.ts @@ -52,6 +52,7 @@ export class InstanceDto extends IntegrationDto { chatwootSignMsg?: boolean; chatwootToken?: string; chatwootUrl?: string; + force?: boolean | string; } export class SetPresenceDto { diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 8bd3c6dbf0..d954636a0a 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -270,6 +270,47 @@ export class WAMonitoringService { } } + public async forceDeleteInstance(instanceName: string) { + try { + await this.waInstances[instanceName]?.sendDataWebhook(Events.REMOVE_INSTANCE, null); + } catch (error) { + this.logger.error(error); + } + + this.clearDelInstanceTime(instanceName); + + try { + await this.cleaningUp(instanceName); + } catch (error) { + this.logger.error(error); + } + + try { + if (this.configService.get('CHATWOOT').ENABLED) { + const instancePath = join(STORE_DIR, 'chatwoot', instanceName); + execFileSync('rm', ['-rf', instancePath]); + } + } catch (error) { + this.logger.error(error); + } + + const instance = await this.prismaRepository.instance.findFirst({ + where: { name: instanceName }, + }); + + if (instance) { + await this.prismaRepository.instance.delete({ where: { name: instanceName } }); + } + + try { + delete this.waInstances[instanceName]; + } catch (error) { + this.logger.error(error); + } + + this.logger.warn(`Instance "${instanceName}" - FORCE REMOVED`); + } + private async setInstance(instanceData: InstanceDto) { const instance = channelController.init(instanceData, { configService: this.configService, From c5f603504ab8c3d8c534687f3147d1660c9c271c Mon Sep 17 00:00:00 2001 From: Adriano Rodrigues Date: Thu, 11 Dec 2025 19:06:48 -0300 Subject: [PATCH 3/3] feat: enhance call handling with LID to phone number conversion Added logic to convert LID to phone number, improved error handling, and ensured call data integrity. This includes resolving pushName and caching mechanisms for better performance. --- .../whatsapp/whatsapp.baileys.service.ts | 137 +++++++++++++++++- 1 file changed, 131 insertions(+), 6 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 2d7cd3ed5b..862b49c1ee 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -2162,20 +2162,145 @@ export class BaileysStartupService extends ChannelStartupService { if (events.call) { const call = events.call[0]; + // Salvar LID original para garantir que nunca será null + const originalFrom = call.from; + const originalChatId = call.chatId; + const isLid = call.from?.endsWith('@lid'); + + // Variáveis para armazenar dados resolvidos + let resolvedRemoteJid = call.from; + let resolvedRemoteJidAlt: string | undefined; + let pushName: string | undefined; + let addressingMode: string = 'pn'; + + // Converter LID para número de telefone ANTES de qualquer operação + if (isLid) { + addressingMode = 'lid'; + resolvedRemoteJidAlt = originalFrom; + + try { + // Primeira tentativa: usar o mapeamento do Baileys + const phoneNumber = await this.client.signalRepository.lidMapping.getPNForLID(call.from as string); + this.logger.debug(`[CALL] getPNForLID result for ${call.from}: ${phoneNumber}`); + + if (phoneNumber) { + resolvedRemoteJid = phoneNumber; + call.from = phoneNumber; + call.chatId = phoneNumber; + } else { + // Segunda tentativa: buscar no cache isOnWhatsapp pelo jidOptions que contém o LID + const cachedNumbers = await getOnWhatsappCache([call.from]); + this.logger.debug(`[CALL] Cache result for ${call.from}: ${JSON.stringify(cachedNumbers)}`); + + if (cachedNumbers.length > 0 && cachedNumbers[0].jidOptions) { + const whatsappJid = cachedNumbers[0].jidOptions.find((jid) => jid.endsWith('@s.whatsapp.net')); + if (whatsappJid) { + resolvedRemoteJid = whatsappJid; + call.from = whatsappJid; + call.chatId = whatsappJid; + } + } else { + // Terceira tentativa: buscar diretamente no banco IsOnWhatsapp pelo LID no jidOptions + const isOnWhatsappRecord = await this.prismaRepository.isOnWhatsapp.findFirst({ + where: { + jidOptions: { contains: call.from }, + }, + }); + this.logger.debug(`[CALL] Direct DB result for ${call.from}: ${JSON.stringify(isOnWhatsappRecord)}`); + + if (isOnWhatsappRecord?.jidOptions) { + const jidOptions = isOnWhatsappRecord.jidOptions.split(','); + const whatsappJid = jidOptions.find((jid) => jid.endsWith('@s.whatsapp.net')); + if (whatsappJid) { + resolvedRemoteJid = whatsappJid; + call.from = whatsappJid; + call.chatId = whatsappJid; + } else { + // Se não encontrou @s.whatsapp.net, usar o remoteJid do registro + resolvedRemoteJid = isOnWhatsappRecord.remoteJid; + call.from = isOnWhatsappRecord.remoteJid; + call.chatId = isOnWhatsappRecord.remoteJid; + } + } + } + } + } catch (error) { + this.logger.warn(`[CALL] Failed to convert LID to phone number: ${error?.message}`); + } + + // Garantir que nunca seja null + if (!call.from) { + call.from = originalFrom; + resolvedRemoteJid = originalFrom; + } + if (!call.chatId) { + call.chatId = originalChatId; + } + } + + // Buscar pushName do contato no banco de dados + try { + const contact = await this.prismaRepository.contact.findFirst({ + where: { + instanceId: this.instanceId, + OR: [{ remoteJid: resolvedRemoteJid }, { remoteJid: originalFrom }], + }, + }); + if (contact?.pushName) { + pushName = contact.pushName; + } + } catch (error) { + this.logger.debug(`[CALL] Failed to get pushName: ${error?.message}`); + } + if (settings?.rejectCall && call.status == 'offer') { - this.client.rejectCall(call.id, call.from); + this.client.rejectCall(call.id, originalFrom); } if (settings?.msgCall?.trim().length > 0 && call.status == 'offer') { - if (call.from.endsWith('@lid')) { - call.from = await this.client.signalRepository.lidMapping.getPNForLID(call.from as string); - } const msg = await this.client.sendMessage(call.from, { text: settings.msgCall }); - this.client.ev.emit('messages.upsert', { messages: [msg], type: 'notify' }); + + // Capturar o número real da mensagem enviada (Baileys resolve o LID internamente) + if (msg?.key?.remoteJid && msg.key.remoteJid.endsWith('@s.whatsapp.net')) { + resolvedRemoteJid = msg.key.remoteJid; + call.from = msg.key.remoteJid; + call.chatId = msg.key.remoteJid; + this.logger.debug(`[CALL] Got phone number from sent message: ${msg.key.remoteJid}`); + + // Salvar no cache para futuras chamadas + await saveOnWhatsappCache([ + { + remoteJid: msg.key.remoteJid, + remoteJidAlt: originalFrom, + lid: 'lid', + }, + ]); + } } - this.sendDataWebhook(Events.CALL, call); + // Determinar se é o evento final da chamada + const finalStatuses = ['timeout', 'reject', 'accept', 'terminate']; + const isFinalEvent = finalStatuses.includes(call.status); + + // Enviar webhook com dados no formato similar ao messages.upsert + const callData = { + ...call, + // Key no formato similar ao messages.upsert + key: { + remoteJid: resolvedRemoteJid, + remoteJidAlt: resolvedRemoteJidAlt, + fromMe: false, + id: call.id, + participant: '', + addressingMode, + }, + pushName, + // Indicar se é o evento final da chamada + isFinalEvent, + }; + + this.sendDataWebhook(Events.CALL, callData); } if (events['connection.update']) {