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
278 changes: 161 additions & 117 deletions src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ export class BaileysStartupService extends ChannelStartupService {
private endSession = false;
private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
private eventProcessingQueue: Promise<void> = 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -574,150 +600,168 @@ export class BaileysStartupService extends ChannelStartupService {
}

private async createClient(number?: string): Promise<WASocket> {
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<ConfigSessionPhone>('CONFIG_SESSION_PHONE');
this.isConnecting = true;
try {
this.instance.authState = await this.defineAuthState();

let browserOptions = {};
const session = this.configService.get<ConfigSessionPhone>('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<proto.IMessage>,
...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<proto.IMessage>,
...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<WASocket> {
Expand Down