diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml new file mode 100644 index 000000000..e7db8f5ac --- /dev/null +++ b/docker-compose.prod.yaml @@ -0,0 +1,42 @@ +services: + api: + container_name: lab_evolution_api + build: + context: . + dockerfile: Dockerfile + image: adrianoftz1999/evolution-api:latest + platform: linux/amd64 + ports: + - 8080:8080 + volumes: + - lab_evolution_instances:/lab_evolution/instances + networks: + - lab_evolution + env_file: + - .env + + postgres: + image: postgres:13 + 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 + ports: + - '6380:6379' + networks: + - lab_evolution + +volumes: + lab_evolution_instances: + pg_data: + +networks: + lab_evolution: + driver: bridge diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 4d468059b..4c93d17c8 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 85c5b69c3..963ecc56b 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/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index e04262999..862b49c1e 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, @@ -2118,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']) { diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 8bd3c6dbf..d954636a0 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,