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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[submodule "evolution-manager-v2"]
path = evolution-manager-v2
url = https://github.com/EvolutionAPI/evolution-manager-v2.git
url = https://github.com/evolution-foundation/evolution-manager-v2.git
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Harassment, discrimination, or abusive behavior will not be tolerated.

### Reporting Bugs

1. Check existing [issues](https://github.com/EvolutionAPI/evolution-api/issues)
1. Check existing [issues](https://github.com/evolution-foundation/evolution-api/issues)
to avoid duplicates
2. Open a new issue with:
- Clear, descriptive title
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</p>

<p align="center">
<a href="https://github.com/EvolutionAPI/evolution-api/releases/latest"><img src="https://img.shields.io/github/v/release/EvolutionAPI/evolution-api?include_prereleases&label=version&color=00ffa7" alt="Latest version" /></a>
<a href="https://github.com/evolution-foundation/evolution-api/releases/latest"><img src="https://img.shields.io/github/v/release/evolution-foundation/evolution-api?include_prereleases&label=version&color=00ffa7" alt="Latest version" /></a>
<a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0" /></a>
<a href="https://docs.evolutionfoundation.com.br"><img src="https://img.shields.io/badge/Docs-evolutionfoundation.com.br-00ffa7" alt="Documentation" /></a>
<a href="https://evolutionfoundation.com.br/community"><img src="https://img.shields.io/badge/Community-Join%20us-white" alt="Community" /></a>
Expand All @@ -25,8 +25,6 @@
<a href="mailto:suporte@evofoundation.com.br">Support</a>
</p>

<div align="center"><img src="./public/images/cover.png" alt="Evolution API" /></div>

---

## About
Expand All @@ -39,7 +37,7 @@ Evolution API began as a WhatsApp controller API based on [CodeChat](https://git

## Part of the Evolution Foundation ecosystem

Evolution API is one of the messaging engines maintained by Evolution Foundation. It is used as a WhatsApp provider by the [Evo CRM Community](https://github.com/EvolutionAPI/evo-crm-community) and other projects in the ecosystem.
Evolution API is one of the messaging engines maintained by Evolution Foundation. It is used as a WhatsApp provider by the [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community) and other projects in the ecosystem.

---

Expand Down Expand Up @@ -82,7 +80,7 @@ Evolution API integrates natively with many platforms:
### Installation

```bash
git clone git@github.com:EvolutionAPI/evolution-api.git
git clone git@github.com:evolution-foundation/evolution-api.git
cd evolution-api

# Install dependencies
Expand Down Expand Up @@ -172,6 +170,14 @@ Local storage or S3/MinIO. Automatic media download from WhatsApp. Optional audi

---

## Hosting

Deploy Evolution API with optimized infrastructure through our HostGator partnership:

[**Evolution API VPS — HostGator**](https://evolution-api.com/vps-evolution-api)

---

## Telemetry

Evolution API collects anonymous telemetry data (routes used, most accessed routes, API version) to help improve the service. **No sensitive or personal data is collected.** This information helps us identify improvements and provide a better experience for users.
Expand Down
2 changes: 1 addition & 1 deletion evolution-manager-v2
9 changes: 9 additions & 0 deletions src/api/controllers/instance.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,15 @@ export class InstanceController {
}

public async connectionState({ instanceName }: InstanceDto) {
// Cross-check the cached state against the real WebSocket before answering, so a
// "zombie open" instance (state cached as 'open' after the socket already dropped)
// is reconciled here too, not only on the next periodic health check.
try {
await this.waMonitor.reconcileInstanceConnection(instanceName);
} catch (error) {
this.logger.error(error);
}

return {
instance: {
instanceName: instanceName,
Expand Down
40 changes: 38 additions & 2 deletions src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,12 @@ export class ChatwootService {

if (state !== 'open') {
const number = command.split(':')[1];
await waInstance.connectToWhatsapp(number);
try {
await waInstance.connectToWhatsapp(number);
} catch (connectError) {
this.logger.error(connectError);
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
}
} else {
await this.createBotMessage(
instance,
Expand Down Expand Up @@ -1601,6 +1606,13 @@ export class ChatwootService {
return { message: 'bot' };
} catch (error) {
this.logger.error(error);
this.logger.error(error?.stack ?? error);

try {
await this.createBotMessage(instance, i18next.t('cw.inbox.requestError'), 'incoming');
} catch (notifyError) {
this.logger.error(notifyError);
}

return { message: 'bot' };
}
Expand Down Expand Up @@ -1948,6 +1960,10 @@ export class ChatwootService {
}

public async eventWhatsapp(event: string, instance: InstanceDto, body: any) {
// Tracks whether createBotQr already succeeded, so a later failure in this same
// handler (e.g. posting the accompanying text message) doesn't also send a
// confusing "QR failed" notification into a conversation that just received the QR.
let qrPosted = false;
try {
const waInstance = this.waMonitor.waInstances[instance.instanceName];

Expand Down Expand Up @@ -2493,7 +2509,17 @@ export class ChatwootService {
const erroQRcode = `🚨 ${i18next.t('qrlimitreached')}`;
return await this.createBotMessage(instance, erroQRcode, 'incoming');
} else {
const fileData = Buffer.from(body?.qrcode.base64.replace('data:image/png;base64,', ''), 'base64');
const qrBase64 = body?.qrcode?.base64;

if (!qrBase64) {
this.logger.error(
`qrcode.updated event received without a valid qrcode.base64 payload for instance ${instance?.instanceName}`,
);
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
return;
}

const fileData = Buffer.from(qrBase64.replace('data:image/png;base64,', ''), 'base64');

const fileStream = new Readable();
fileStream._read = () => {};
Expand All @@ -2507,6 +2533,7 @@ export class ChatwootService {
fileStream,
`${instance.instanceName}.png`,
);
qrPosted = true;

let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`;

Expand All @@ -2524,6 +2551,15 @@ export class ChatwootService {
}
} catch (error) {
this.logger.error(error);
this.logger.error(error?.stack ?? error);

if (event === 'qrcode.updated' && !qrPosted) {
try {
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
} catch (notifyError) {
this.logger.error(notifyError);
}
}
}
}

Expand Down
90 changes: 89 additions & 1 deletion src/api/services/monitor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { InstanceDto } from '@api/dto/instance.dto';
import { ProviderFiles } from '@api/provider/sessions';
import { PrismaRepository } from '@api/repository/repository.service';
import { channelController } from '@api/server.module';
import { Events, Integration } from '@api/types/wa.types';
import { Events, Integration, wa } from '@api/types/wa.types';
import { CacheConf, Chatwoot, ConfigService, Database, DelInstance, ProviderSession } from '@config/env.config';
import { Logger } from '@config/logger.config';
import { INSTANCE_DIR, STORE_DIR } from '@config/path.config';
Expand All @@ -26,6 +26,7 @@ export class WAMonitoringService {
) {
this.removeInstance();
this.noConnection();
this.startConnectionHealthCheck();

Object.assign(this.db, configService.get<Database>('DATABASE'));
Object.assign(this.redis, configService.get<CacheConf>('CACHE'));
Expand All @@ -42,6 +43,12 @@ export class WAMonitoringService {

private readonly providerSession: ProviderSession;

// Health check: how often (ms) we probe instances cached as 'open' to confirm the
// underlying WebSocket is really still connected (mitigates the "zombie open" state,
// since connectionStatus is otherwise 100% event-driven from connection.update).
private readonly HEALTH_CHECK_INTERVAL_MS = 30_000;
private healthCheckInterval: NodeJS.Timeout;

public delInstanceTime(instance: string) {
const time = this.configService.get<DelInstance>('DEL_INSTANCE');
if (typeof time === 'number' && time > 0) {
Expand Down Expand Up @@ -83,6 +90,87 @@ export class WAMonitoringService {
}
}

/**
* Starts the periodic reconciliation of cached connection state ("zombie open" mitigation).
* connectionStatus.state is otherwise only ever written from inside the Baileys
* 'connection.update' handler, so if that event is ever missed (process hiccup, socket
* torn down outside Baileys' own reconnection flow, etc.) an instance can stay marked
* 'open' in memory/DB forever even though the WebSocket is gone.
*/
private startConnectionHealthCheck() {
this.healthCheckInterval = setInterval(() => {
this.checkInstancesConnection();
}, this.HEALTH_CHECK_INTERVAL_MS);
}

private async checkInstancesConnection() {
for (const instanceName of Object.keys(this.waInstances)) {
try {
await this.reconcileInstanceConnection(instanceName);
} catch (error) {
// A failure checking one instance must never abort the loop or affect the others.
this.logger.error(`Health check failed for instance "${instanceName}": ${error}`);
}
}
}

/**
* Confirms that an instance cached as 'open' still has a real, open WebSocket
* (client.ws.isOpen, per Baileys' AbstractSocketClient). If it does not, forces
* stateConnection to 'close', persists it via the same Prisma fields used by
* BaileysStartupService.connectionUpdate() on a genuine close, and re-emits
* CONNECTION_UPDATE through the instance's own sendDataWebhook (no duplicated logic).
*
* Used both by the periodic health check above and on-demand by
* InstanceController.connectionState() before answering a status request.
*/
public async reconcileInstanceConnection(instanceName: string): Promise<wa.StateConnection | undefined> {
const waInstance = this.waInstances[instanceName];

if (!waInstance || waInstance.connectionStatus?.state !== 'open') {
return waInstance?.connectionStatus;
}

const client = waInstance.client;

// Only instances with an initialized Baileys client expose ws.isOpen; instances
// without one (still connecting, non-Baileys channel, etc.) are left untouched.
if (!client?.ws || client.ws.isOpen) {
return waInstance.connectionStatus;
}

this.logger.warn(
`Instance "${instanceName}" is cached as 'open' but its WebSocket is not open (isOpen=false). Reconciling status to 'close'.`,
);

waInstance.stateConnection = { state: 'close', statusReason: 428 };

try {
await this.prismaRepository.instance.update({
where: { id: waInstance.instanceId },
data: {
connectionStatus: 'close',
disconnectionAt: new Date(),
disconnectionReasonCode: 428,
disconnectionObject: JSON.stringify({ reason: 'health-check: websocket not open' }),
},
});
} catch (error) {
this.logger.error(`Failed to persist reconciled connection state for "${instanceName}": ${error}`);
}

try {
await waInstance.sendDataWebhook?.(Events.CONNECTION_UPDATE, {
instance: instanceName,
...waInstance.stateConnection,
});
} catch (error) {
this.logger.error(`Failed to emit CONNECTION_UPDATE for "${instanceName}": ${error}`);
}

return waInstance.connectionStatus;
}

public async instanceInfo(instanceNames?: string[]): Promise<any> {
if (instanceNames && instanceNames.length > 0) {
const inexistentInstances = instanceNames ? instanceNames.filter((instance) => !this.waInstances[instance]) : [];
Expand Down
2 changes: 2 additions & 0 deletions src/utils/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"qrgeneratedsuccesfully": "QRCode successfully generated!",
"scanqr": "Scan this QR code within the next 40 seconds.",
"qrlimitreached": "QRCode generation limit reached, to generate a new QRCode, send the 'init' message again.",
"cw.inbox.qrError": "🚨 Failed to generate the QR Code. Please try again by sending 'init' in this conversation.",
"cw.inbox.requestError": "⚠️ An error occurred while processing your request. If you were trying to connect via QR Code, please try again by sending 'init'.",
"cw.inbox.connected": "🚀 Connection successfully established!",
"cw.inbox.disconnect": "🚨 Disconnecting WhatsApp from inbox *{{inboxName}}*.",
"cw.inbox.alreadyConnected": "🚨 {{inboxName}} instance is connected.",
Expand Down
2 changes: 2 additions & 0 deletions src/utils/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"qrgeneratedsuccesfully": "Código QR generado exitosamente!",
"scanqr": "Escanea este código QR en los próximos 40 segundos.",
"qrlimitreached": "🚨 Se alcanzó el límite de generación de QRCode. Para generar un nuevo QRCode, envíe el mensaje 'init' nuevamente.",
"cw.inbox.qrError": "🚨 No se pudo generar el código QR. Por favor, intente nuevamente enviando 'init' en esta conversación.",
"cw.inbox.requestError": "⚠️ Ocurrió un error al procesar su solicitud. Si estaba intentando conectar mediante código QR, intente nuevamente enviando 'init'.",
"cw.inbox.connected": "🚀 ¡Conexión establecida exitosamente!",
"cw.inbox.disconnect": "🚨 Instancia *{{inboxName}}* desconectado de Whatsapp.",
"cw.inbox.alreadyConnected": "🚨 La instancia {{inboxName}} está conectada.",
Expand Down
2 changes: 2 additions & 0 deletions src/utils/translations/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"qrgeneratedsuccesfully": "QRCode gerado com sucesso!",
"scanqr": "Escaneie o QRCode com o WhatsApp nos próximos 40 segundos.",
"qrlimitreached": "Limite de geração de QRCode atingido! Para gerar um novo QRCode, envie o texto 'init' nesta conversa.",
"cw.inbox.qrError": "🚨 Falha ao gerar o QR Code. Por favor, tente novamente enviando 'init' nesta conversa.",
"cw.inbox.requestError": "⚠️ Ocorreu um erro ao processar sua solicitação. Se você estava tentando conectar via QR Code, tente novamente enviando 'init'.",
"cw.inbox.connected": "🚀 Conectado com sucesso!",
"cw.inbox.disconnect": "🚨 Instância *{{inboxName}}* desconectada do WhatsApp.",
"cw.inbox.alreadyConnected": "🚨 Instância *{{inboxName}}* já está conectada.",
Expand Down