Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docker-compose.prod.yaml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 33 additions & 1 deletion src/api/controllers/instance.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>('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];
Expand Down
1 change: 1 addition & 0 deletions src/api/dto/instance.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class InstanceDto extends IntegrationDto {
chatwootSignMsg?: boolean;
chatwootToken?: string;
chatwootUrl?: string;
force?: boolean | string;
}

export class SetPresenceDto {
Expand Down
183 changes: 176 additions & 7 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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>('DATABASE').PROVIDER;
Expand Down Expand Up @@ -770,7 +813,8 @@ export class BaileysStartupService extends ChannelStartupService {
generateHighQualityLinkPreview: true,
getMessage: async (key) => (await this.getMessage(key)) as Promise<proto.IMessage>,
// 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,
Expand Down Expand Up @@ -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']) {
Expand Down
41 changes: 41 additions & 0 deletions src/api/services/monitor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>('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,
Expand Down