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/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..8d20dc83aa 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -251,6 +251,11 @@ export class BaileysStartupService extends ChannelStartupService { private endSession = false; private logBaileys = this.configService.get('LOG').BAILEYS; private eventProcessingQueue: Promise = Promise.resolve(); + // Prevents concurrent connectToWhatsapp/createClient calls from racing on the + // same session (e.g. Chatwoot-triggered manual reconnect firing while the + // automatic reconnect from a 'close' event is already in flight), which can + // itself cause Baileys to emit DisconnectReason.connectionReplaced (440). + private isConnecting = false; // Cache TTL constants (in seconds) private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing @@ -425,8 +430,29 @@ export class BaileysStartupService extends ChannelStartupService { if (connection === 'close') { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; - const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; + // connectionReplaced (440) means the session was taken over elsewhere (another + // device/tab, or a concurrent connection attempt on our own side). Auto-reconnecting + // here would retry with credentials that the takeover may have already invalidated, + // producing a close -> reconnect -> close loop. Treat it like the other terminal + // codes: stop, surface a clear signal, and require an explicit new connection/QR scan. + const codesToNotReconnect = [ + DisconnectReason.loggedOut, + DisconnectReason.forbidden, + DisconnectReason.connectionReplaced, + 402, + 406, + ]; const shouldReconnect = !codesToNotReconnect.includes(statusCode); + + if (statusCode === DisconnectReason.connectionReplaced) { + this.logger.warn( + `Instance ${this.instance.name}: connection replaced (DisconnectReason.connectionReplaced/440). ` + + 'This session was opened elsewhere (another device/tab, or a concurrent connection attempt) ' + + 'and will NOT be auto-reconnected with the current credentials. A new QR Code scan (or explicit ' + + 'reconnect) will be required.', + ); + } + if (shouldReconnect) { await this.connectToWhatsapp(this.phoneNumber); } else { @@ -574,150 +600,168 @@ export class BaileysStartupService extends ChannelStartupService { } private async createClient(number?: string): Promise { - this.instance.authState = await this.defineAuthState(); + // Guard against concurrent connection attempts on the same instance (e.g. a + // manual reconnect from Chatwoot/the API firing while the automatic reconnect + // triggered by a 'close' event is still in flight). Two Baileys sockets racing + // on the same session is a known trigger for DisconnectReason.connectionReplaced. + if (this.isConnecting) { + this.logger.warn( + `Instance ${this.instance.name}: a connection attempt is already in progress; ` + + 'skipping this concurrent createClient call to avoid racing sockets on the same session.', + ); + return this.client; + } - const session = this.configService.get('CONFIG_SESSION_PHONE'); + this.isConnecting = true; + try { + this.instance.authState = await this.defineAuthState(); - let browserOptions = {}; + const session = this.configService.get('CONFIG_SESSION_PHONE'); - if (number || this.phoneNumber) { - this.phoneNumber = number; + let browserOptions = {}; - this.logger.info(`Phone number: ${number}`); - } else { - const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; - browserOptions = { browser }; + if (number || this.phoneNumber) { + this.phoneNumber = number; - this.logger.info(`Browser: ${browser}`); - } + this.logger.info(`Phone number: ${number}`); + } else { + const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()]; + browserOptions = { browser }; - const baileysVersion = await fetchLatestWaWebVersion({}); - const version = baileysVersion.version; - const log = `Baileys version: ${version.join('.')}`; + this.logger.info(`Browser: ${browser}`); + } - this.logger.info(log); + const baileysVersion = await fetchLatestWaWebVersion({}); + const version = baileysVersion.version; + const log = `Baileys version: ${version.join('.')}`; - this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); + this.logger.info(log); - let options; + this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); - if (this.localProxy?.enabled) { - this.logger.info('Proxy enabled: ' + this.localProxy?.host); + let options; - if (this.localProxy?.host?.includes('proxyscrape')) { - try { - const response = await axios.get(this.localProxy?.host); - const text = response.data; - const proxyUrls = text.split('\r\n'); - const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); - const proxyUrl = 'http://' + proxyUrls[rand]; - options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; - } catch { - this.localProxy.enabled = false; + if (this.localProxy?.enabled) { + this.logger.info('Proxy enabled: ' + this.localProxy?.host); + + if (this.localProxy?.host?.includes('proxyscrape')) { + try { + const response = await axios.get(this.localProxy?.host); + const text = response.data; + const proxyUrls = text.split('\r\n'); + const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); + const proxyUrl = 'http://' + proxyUrls[rand]; + options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; + } catch { + this.localProxy.enabled = false; + } + } else { + options = { + agent: makeProxyAgent({ + host: this.localProxy.host, + port: this.localProxy.port, + protocol: this.localProxy.protocol, + username: this.localProxy.username, + password: this.localProxy.password, + }), + fetchAgent: makeProxyAgentUndici({ + host: this.localProxy.host, + port: this.localProxy.port, + protocol: this.localProxy.protocol, + username: this.localProxy.username, + password: this.localProxy.password, + }), + }; } - } else { - options = { - agent: makeProxyAgent({ - host: this.localProxy.host, - port: this.localProxy.port, - protocol: this.localProxy.protocol, - username: this.localProxy.username, - password: this.localProxy.password, - }), - fetchAgent: makeProxyAgentUndici({ - host: this.localProxy.host, - port: this.localProxy.port, - protocol: this.localProxy.protocol, - username: this.localProxy.username, - password: this.localProxy.password, - }), - }; } - } - const socketConfig: UserFacingSocketConfig = { - ...options, - version, - logger: P({ level: this.logBaileys }), - printQRInTerminal: false, - auth: { - creds: this.instance.authState.state.creds, - keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), - }, - msgRetryCounterCache: this.msgRetryCounterCache, - generateHighQualityLinkPreview: true, - getMessage: async (key) => (await this.getMessage(key)) as Promise, - ...browserOptions, - markOnlineOnConnect: this.localSettings.alwaysOnline, - retryRequestDelayMs: 350, - maxMsgRetryCount: 4, - fireInitQueries: true, - connectTimeoutMs: 30_000, - keepAliveIntervalMs: 30_000, - qrTimeout: 45_000, - emitOwnEvents: false, - shouldIgnoreJid: (jid) => { - if (this.localSettings.syncFullHistory && isJidGroup(jid)) { - return false; - } + const socketConfig: UserFacingSocketConfig = { + ...options, + version, + logger: P({ level: this.logBaileys }), + printQRInTerminal: false, + auth: { + creds: this.instance.authState.state.creds, + keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any), + }, + msgRetryCounterCache: this.msgRetryCounterCache, + generateHighQualityLinkPreview: true, + getMessage: async (key) => (await this.getMessage(key)) as Promise, + ...browserOptions, + markOnlineOnConnect: this.localSettings.alwaysOnline, + retryRequestDelayMs: 350, + maxMsgRetryCount: 4, + fireInitQueries: true, + connectTimeoutMs: 30_000, + keepAliveIntervalMs: 30_000, + qrTimeout: 45_000, + emitOwnEvents: false, + shouldIgnoreJid: (jid) => { + if (this.localSettings.syncFullHistory && isJidGroup(jid)) { + return false; + } - const isGroupJid = this.localSettings.groupsIgnore && isJidGroup(jid); - const isBroadcast = !this.localSettings.readStatus && isJidBroadcast(jid); - const isNewsletter = isJidNewsletter(jid); + const isGroupJid = this.localSettings.groupsIgnore && isJidGroup(jid); + const isBroadcast = !this.localSettings.readStatus && isJidBroadcast(jid); + const isNewsletter = isJidNewsletter(jid); - return isGroupJid || isBroadcast || isNewsletter; - }, - syncFullHistory: this.localSettings.syncFullHistory, - shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { - return this.historySyncNotification(msg); - }, - cachedGroupMetadata: this.getGroupMetadataCache, - userDevicesCache: this.userDevicesCache, - transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 }, - patchMessageBeforeSending(message) { - if ( - message.deviceSentMessage?.message?.listMessage?.listType === proto.Message.ListMessage.ListType.PRODUCT_LIST - ) { - message = JSON.parse(JSON.stringify(message)); + return isGroupJid || isBroadcast || isNewsletter; + }, + syncFullHistory: this.localSettings.syncFullHistory, + shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => { + return this.historySyncNotification(msg); + }, + cachedGroupMetadata: this.getGroupMetadataCache, + userDevicesCache: this.userDevicesCache, + transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 }, + patchMessageBeforeSending(message) { + if ( + message.deviceSentMessage?.message?.listMessage?.listType === + proto.Message.ListMessage.ListType.PRODUCT_LIST + ) { + message = JSON.parse(JSON.stringify(message)); - message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } + message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } - if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { - message = JSON.parse(JSON.stringify(message)); + if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) { + message = JSON.parse(JSON.stringify(message)); - message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; - } + message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT; + } - return message; - }, - }; + return message; + }, + }; - this.endSession = false; + this.endSession = false; - this.client = makeWASocket(socketConfig); + this.client = makeWASocket(socketConfig); - if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { - useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); - } + if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { + useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true); + } - this.eventHandler(); + this.eventHandler(); - this.client.ws.on('CB:call', (packet) => { - console.log('CB:call', packet); - const payload = { event: 'CB:call', packet: packet }; - this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); - }); + this.client.ws.on('CB:call', (packet) => { + console.log('CB:call', packet); + const payload = { event: 'CB:call', packet: packet }; + this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); + }); - this.client.ws.on('CB:ack,class:call', (packet) => { - console.log('CB:ack,class:call', packet); - const payload = { event: 'CB:ack,class:call', packet: packet }; - this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); - }); + this.client.ws.on('CB:ack,class:call', (packet) => { + console.log('CB:ack,class:call', packet); + const payload = { event: 'CB:ack,class:call', packet: packet }; + this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); + }); - this.phoneNumber = number; + this.phoneNumber = number; - return this.client; + return this.client; + } finally { + this.isConnecting = false; + } } public async connectToWhatsapp(number?: string): Promise {