diff --git a/.gitmodules b/.gitmodules index ef5b3e63cf..54a177465b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 595600cc05..8aa5df1c7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index d9ea137e14..4586e4028a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Latest version + Latest version License: Apache 2.0 Documentation Community @@ -25,8 +25,6 @@ Support

-
Evolution API
- --- ## About @@ -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. --- @@ -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 @@ -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. diff --git a/evolution-manager-v2 b/evolution-manager-v2 index f054b9bc28..3137df4695 160000 --- a/evolution-manager-v2 +++ b/evolution-manager-v2 @@ -1 +1 @@ -Subproject commit f054b9bc28083152d4948f835e3346fd0add39db +Subproject commit 3137df469504ce211c68e7b35f0706497ac1b95f diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 6a69106881..a79ae21e61 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -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, diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 906fff1881..408785724f 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -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, @@ -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' }; } @@ -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]; @@ -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 = () => {}; @@ -2507,6 +2533,7 @@ export class ChatwootService { fileStream, `${instance.instanceName}.png`, ); + qrPosted = true; let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`; @@ -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); + } + } } } diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 438530b57e..23b92c1448 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -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'; @@ -26,6 +26,7 @@ export class WAMonitoringService { ) { this.removeInstance(); this.noConnection(); + this.startConnectionHealthCheck(); Object.assign(this.db, configService.get('DATABASE')); Object.assign(this.redis, configService.get('CACHE')); @@ -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('DEL_INSTANCE'); if (typeof time === 'number' && time > 0) { @@ -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 { + 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 { if (instanceNames && instanceNames.length > 0) { const inexistentInstances = instanceNames ? instanceNames.filter((instance) => !this.waInstances[instance]) : []; diff --git a/src/utils/translations/en.json b/src/utils/translations/en.json index 43dfd8d213..0a31dc1b34 100644 --- a/src/utils/translations/en.json +++ b/src/utils/translations/en.json @@ -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.", diff --git a/src/utils/translations/es.json b/src/utils/translations/es.json index be830885ed..1bf0f5a824 100644 --- a/src/utils/translations/es.json +++ b/src/utils/translations/es.json @@ -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.", diff --git a/src/utils/translations/pt-BR.json b/src/utils/translations/pt-BR.json index 31bf468a9b..f55557cf4d 100644 --- a/src/utils/translations/pt-BR.json +++ b/src/utils/translations/pt-BR.json @@ -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.",