Repository navigation
Review: the fork against Evolution API 2.3.7 - #2
Open
AlmogCohen wants to merge 179 commits into
Open
AlmogCohen wants to merge 179 commits into
AlmogCohen wants to merge 179 commits into
Conversation
Adds FORK.md (why the fork exists, the red-then-green rule, the base) and NOTICE (Evolution Foundation's attribution from upstream main, plus the modification notice Apache 2.0 section 4 asks for). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.3.7 ships 7.0.0-rc.9, which is inside the range of CVE-2026-48063 (message and history-sync spoofing, rc.1 to rc11, GHSA-qvv5-jq5g-4cgg). rc13 is also the first to learn @lid to phone mappings from the history sync. Pinned exactly: an RC is not a range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Baileys Evolution has no test suite, and test/ has been in .gitignore since the first commit. This adds one: vitest 4.1.11 runs the TypeScript source directly, the real BaileysStartupService is driven through Baileys' real event buffer, and Prisma and the server module are replaced by in-memory fakes. - Baileys resolves from the installed package (package.json pins it), and deep imports reach the same build. BAILEYS_DIR points the suite at another build; BAILEYS_EXPECT fails the run when the resolved version differs. - Tests start from a production configuration (the minimal profile: only the instance stored, local cache, WARN and above). A test that needs storage asks for the stored profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typecheck and the vitest suite on the pinned Baileys, on every push to a fork/ branch and on pull requests. A weekly job runs the same suite against the newest Baileys release. The upstream workflows are left as they are: none of them triggers on fork/ branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md gains a first section for this fork: every change starts with a failing test committed alone, then the fix; how the harness runs; profiles; what to assert. CLAUDE.md points at it. These are this fork's rules, not Evolution's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the three auth stores (prisma, redis-db, provider-files) saves a real app-state sync key, is opened again as after a restart, and decodes a real encrypted contact patch with the key it reads back. Fails today: the stores revive the key with AppStateSyncKeyData.create(), which leaves keyData a base64 string, so the patch is skipped and no name comes back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a JSON round trip an app-state sync key's keyData is a base64 string. AppStateSyncKeyData.create() keeps it a string, so Baileys derives the wrong keys and skips every app-state patch after a restart. fromObject() decodes it back to bytes, as Baileys' own multi-file store does. Applied to the prisma, redis-db and provider-files stores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys emits groups.update, which every transport upper-cases to GROUPS_UPDATE before comparing it with the subscription. /webhook/set (and every /<transport>/set) accepts only GROUP_UPDATE, and the global env config is keyed GROUP_UPDATE, so a subscription to group updates never matches. Through the real WebhookController to a local HTTP destination: subscribed as GROUP_UPDATE (the spelling /webhook/set accepts) or GROUPS_UPDATE, per instance and global, the event must arrive, and other events must be unaffected. The same checks run through the websocket, rabbitmq, nats, sqs, kafka and pusher controllers with recording clients. Fails today for GROUP_UPDATE, per instance in all seven transports and global in webhook, rabbitmq, nats, kafka and pusher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The event is groups.update (GROUPS_UPDATE once upper-cased), but the /<transport>/set schemas store GROUP_UPDATE and the global env configs are keyed GROUP_UPDATE (except SQS's), so group updates reached no subscriber. A shared isSubscribed() in event.controller.ts accepts both spellings and replaces the identical comparisons in the webhook, websocket, rabbitmq, sqs, nats, kafka and pusher controllers, per instance and global. No stored value is renamed, so existing subscriptions keep working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys calls the socket's getMessage to answer a retry request for a
message this instance sent once the message has left its recent-message
cache, and relays any truthy answer under the original id. Evolution
answers a database miss with { conversation: '' }. Under the minimal
profile (no messages stored) every lookup misses. The test asks for an
unknown id under both profiles and for a sent message under minimal, and
expects undefined; a message stored under the stored profile must still
come back as stored. Fails today with { conversation: '' }.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lookup that finds no row used to throw on webMessageInfo[0].message and
be answered by the catch with { conversation: '' }, which Baileys relays
as the retried message under the original id. Return undefined when
nothing is stored, which Baileys reads as "not available" and sends
nothing. A lookup that throws (database error) still gets the old answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….upsert Baileys (rc13+) learns which phone number each private @lid belongs to and hands the mappings over as lidPnMappings on messaging-history.set and as lid-mapping.update events. Evolution 2.3.7 reads neither, so a consumer never learns that an @lid chat is a known phone number. The test drives history through the production path (Baileys' own processMessage inside ev.createBufferedFunction, a real signal repository) and asserts the exact CONTACTS_UPSERT items a consumer reads a mapping from: { remoteJid: <phone>, pushName: null, lid: <lid>, phoneNumber: <phone>, instanceId }. On the unchanged code all seven cases fail with no mapping item emitted at all (expected [ ...mappings ], received []), because nothing forwards either source: - (a) an @lid-keyed conversation, mapped by phoneNumberToLidMappings or its own pnJid - (b) a phone-keyed conversation whose lidJid is set - (c) a mapping with no conversation of its own - (d) the mapping in a later, and in an earlier, history batch than its conversation - (e) a live lid-mapping.update from pnForLidChatAction - the negative control (no mapping for an unmapped @lid, no unrelated pair linked), whose positive half expects the two real pairs Forwarding the event field alone is not enough: Baileys' event buffer drops lidPnMappings when it flushes a buffered messaging-history.set (rc14 lib/Utils/event-buffer.js, consolidateEvents). Checked against the spike fix that recovers mappings by asking the signal repository for each @lid chat or contact id: (a), (e) and the earlier-batch (d) pass, while (b), (c), the later-batch (d) and the negative control's unconversationed pair still fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every @lid to phone mapping Baileys learns now reaches the consumer as one CONTACTS_UPSERT item: { remoteJid: <phone>, pushName: null, lid: <lid>, phoneNumber: <phone>, instanceId }. Pairs are oriented, normalized to user jids and de-duplicated within a batch; anything that is not an @lid and a phone jid is skipped. - Live mappings: lid-mapping.update is not a bufferable event, so it reaches ev.process intact and is forwarded there. - History mappings: Baileys puts every mapping of a batch on messaging-history.set as lidPnMappings (phoneNumberToLidMappings, plus each conversation's pnJid or lidJid, lib/Utils/history.js:45-78), but history is processed inside ev.createBufferedFunction and the buffer drops the field when it consolidates the batch (lib/Utils/event-buffer.js:543-556). In the test's buffered path the handler receives lidPnMappings undefined in every batch. So eventHandler() now wraps client.ev.emit once per socket and reads lidPnMappings where Baileys emits it, before the buffer sees it, queued on eventProcessingQueue like every other event. Plain TypeScript, Baileys is not patched, and nothing is looked up. Chosen because it is the only option that covers every case. Rejected: - Reading lid / phoneNumber off the contacts Baileys builds from history: they survive the buffer, but a contact carries only its own conversation's lidJid or pnJid (history.js:56-62). A mapping that exists only in phoneNumberToLidMappings (history.js:45-49) has no contact, so an @lid conversation mapped that way, a mapping with no conversation, and a mapping in a later batch than its conversation are all lost. - Looking the mapping up in the signal repository: getPNForLID is local (cache, then the keys store, lib/Signal/lid-mapping.js:199-270), but getLIDForPN falls back to a USYNC query on a miss (lid-mapping.js:155-176), so a phone-keyed conversation cannot be resolved without the network, and the key store has no way to enumerate its entries, so a mapping with no conversation id to ask about, or one arriving in a batch with no conversations, cannot be found at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… certain Each contacts.upsert webhook item now carries saved. It is true only for a name from an app-state contact action (fullName or firstName), and false for history names (displayName, chat name or username), push names, a message's pushName, and a contact action name that is just the username. Every existing field is unchanged, and saved is not written to the stored row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R,WARN
Drives each path that handles a message, contact or call with a distinctive
text, phone number and push name, under the minimal and stored profiles,
and asserts none of the three is printed. captureOutput now also catches
direct writes to fd 1 and 2, which is how pino (the logger Evolution hands
Baileys) prints.
Paths that leak on 2.3.7:
- ordinary incoming message and placeholder resend: console.log(messageRaw)
prints text, sender number and push name; the resend also prints the
whole message after "Message received from phone"
- on-demand history sync: console.log dumps every message in the batch
- message that failed to decrypt ("No session record"): Baileys' error log
"failed to decrypt message" prints the key with the sender's JID, and
Evolution's WARN "Message ignored with messageStubParameters" prints the
whole message with the JID and push name
- Baileys "error in handling message": prints the stanza (from, notify,
body) through the pino logger Evolution configures
- incoming call: console.log of the CB:call and CB:ack,class:call stanzas
prints the caller's JID and push name
- group metadata cache lookup: console.log "Cache request for group" prints
the group JID, which embeds the creator's number in old-format ids
- group participants update when the lookup fails: ERROR prints the group JID
- raw node through baileysSendNode: console.log prints the whole stanza
- stored profile only: "Original message not found for update" WARN prints
the key, and "Chat insert record ignored" prints the chat JID
- status update under minimal: a bare console.log "CACHE:" bypasses LOG_LEVEL
Guards that already pass: a history batch and an outgoing text message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A deployment with LOG_LEVEL=ERROR,WARN still printed message text, phone numbers and push names: bare console.log dumps that ignore LOG_LEVEL, WARN and ERROR lines that serialised whole messages or keys, and the pino logger handed to Baileys, which at level error prints message keys, sender JIDs and whole stanzas. - Baileys gets makeBaileysLogger (src/utils/log-privacy.ts): the same level, but every logged object is reduced to message id, chat type, message type, the session-record flag, counts and the error's name and message, with anything shaped like a JID or a phone number masked. Used for the socket, the signal key store and media downloads. - Removed console.log(messageRaw); the full payload stays available at VERBOSE through the existing logger.verbose. - On-demand history, "Message received from phone", CB:call and CB:ack,class:call, CACHE, group cache lookups and baileysSendNode now go through the logger (INFO or VERBOSE) with ids and counts only. - "Message ignored with messageStubParameters" prints id, chat type and the matched decrypt error instead of the whole message. - "Original message not found for update" prints the message id, not the key; "Chat insert record ignored" prints the chat type, not the JID; the group participants error drops the group JID. Webhook payloads and every other behaviour are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A local CDN and local HTTP CONNECT and SOCKS5 proxies on 127.0.0.1, with the instance's proxy set the way /proxy/set and a connect set it. Downloads via getBase64FromMediaMessage (the /chat/getBase64FromMediaMessage route and the S3 storage path) and the messages.upsert webhook base64 path must reach the CDN through the proxy. On the current code they reach it directly: the socket gets the proxy, but Baileys downloads with fetch and only a `dispatcher` in the download options routes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Baileys 7 downloads media with fetch, which ignores the socket's agent and fetchAgent and routes only through a `dispatcher` in the download options. Every downloadMediaMessage and downloadContentFromMessage call in the Baileys service (getBase64FromMediaMessage and its fallback, and the webhook base64 downloads in messages.upsert and after sending) now passes the instance's proxy as an undici dispatcher (ProxyAgent for http/https, fetch-socks for socks4/socks5), built by the existing makeProxyAgentUndici. It is the socket's own fetchAgent when the socket was created with one, so a proxyscrape exit is the same exit, and it is rebuilt when /proxy/set changes the proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gh it Runs Evolution's real connect with makeWASocket replaced by a spy, then runs Baileys' own getWAUploadToServer with the socket config Evolution built, against a local HTTPS media host (a test certificate added to the default CAs, verification on) through local HTTP CONNECT and SOCKS5 proxies. Today the upload fails on every host: Baileys uploads with http.request under Node and passes fetchAgent as its agent, and Evolution's fetchAgent is an undici dispatcher, which http.request refuses with ERR_INVALID_ARG_TYPE. A connection guard in the helpers refuses any socket not to the loopback address and records what tried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under Node, Baileys uploads media with http.request and passes the socket's fetchAgent as its agent. Evolution set fetchAgent to an undici dispatcher, which http.request refuses, so every media send on an instance with a proxy failed with "Media upload failed on all hosts". fetchAgent is now the same kind of agent as the websocket's (makeProxyAgent: https-proxy-agent or socks-proxy-agent), and the undici dispatcher is kept for downloads only. The proxy is resolved once per connect, so a proxyscrape exit stays one exit for the socket, uploads and downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs Evolution's real connect with a proxy set and makeWASocket replaced by a spy. The proxy maps web.whatsapp.com and raw.githubusercontent.com to local HTTPS servers, and the connection guard refuses anything else. Today both the sw.js request (axios) and the fallback to Baileys' version file (fetch) go straight out, so the guard records web.whatsapp.com:443 and raw.githubusercontent.com:443 and the proxy sees nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roxy createClient asked web.whatsapp.com for its version (and, failing that, GitHub for Baileys' version file) before resolving the instance's proxy, so every account's connect was preceded by a request from the server's own IP. The proxy is now resolved first, the sw.js request (axios) goes through the socket's proxy agent with axios' own env proxy off, and the Baileys fallback (fetch) through the undici dispatcher. Without a proxy nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp starts loadProxy() without waiting for it and goes on to build the socket. With the Proxy row read taking a database round trip (50ms here), the socket config carries no proxy agent and the version fetch goes out directly: the connection guard records web.whatsapp.com and raw.githubusercontent.com, and a connection opened with the socket's agent goes straight out too. Covered for the first connect after boot and for a second connect of the same service (restart, reconnect after close), where loadProxy first sets enabled to false and so drops a proxy that was already loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp now awaits loadProxy() before createClient, so the socket, the version fetch and media are built from the Proxy row rather than from whatever the read had reached. Every connect goes through here: the boot auto-connect, /instance/create, /instance/connect, /instance/restart, the reconnect after a closed connection and the Chatwoot reconnect. A failed Proxy read now fails the connect instead of connecting without the proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp starts loadSettings() without waiting for it and builds the socket from localSettings. With the Setting row read taking a database round trip (50ms here), the first connect after boot gets syncFullHistory and markOnlineOnConnect undefined, and shouldIgnoreJid keeps groups that the instance ignores. Two stored rows, since with full history on Evolution keeps groups even when groupsIgnore is set. The reconnect cases hold on the current code (loadSettings does not clear before reading) and guard that path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
connectToWhatsapp now awaits loadSettings() before createClient, so the socket is built from the instance's Setting row (syncFullHistory, groupsIgnore, readStatus, alwaysOnline, and the wavoip token read right after) rather than from defaults when the read is slower than the rest of the connect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… session keyExists and getAuthKey swallowed every database error and returned false/null, so opening the Prisma auth store during a database blip read as "no session": it started fresh creds (initAuthCreds) and saved them over the linked session once the database answered, unlinking the account. A failed session read now propagates, so the open (and the connect) fails and can be retried with the stored creds intact. A genuinely absent row still starts a fresh session. saveKey keeps its existence check inside its own try, so a failed check skips the save instead of creating a second row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Prisma auth store now writes a key through writeFileAtomic (src/utils/atomic-file.ts): the value goes to a temp file in the same directory (opened exclusively), is flushed with fsync, and is renamed over the key file, which POSIX makes atomic; the directory is then flushed, so the rename survives a power loss (renames that land together share one directory flush). A failed write removes its temp file, leaves the previous value and rejects, so keys.set fails as saveCreds does. Writes of the same file run one at a time in call order, so the last one asked for stays. A killed writer can leave its temp file behind; the store removes those when it opens its folder (a temp file of a process that no longer runs, or this process's own that it is not writing), and leaves one of another running process alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…marker The marker next to the session's key files is written in place (fs.writeFile). A write that fails partway (injected here as a full disk: half the bytes land, then ENOSPC) rejects as it should, but leaves half a marker over the whole one, which reads as pending with no instance name. The previous marker must survive, with no temp file left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
writeLogoutMarker writes through writeFileAtomic, as the key files do: a temp file in the same directory, fsync, rename over the marker, fsync of the directory. A write that fails keeps the previous marker, removes its temp file and rejects, so the logout or delete still answers 500 as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laced whole Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NOTICE said the policy was included in this repository; 2.3.7 does not carry TRADEMARKS.md, so it now links the upstream file. It also states that the fork does not change the user interface or its brand assets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ket, a pairing-code link) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| const env = { ...process.env, NODE_PATH: join(REPO, 'node_modules'), CACHE_REDIS_ENABLED: 'false' }; | ||
| const argv = [WRITER, ...args]; | ||
| const child = fileSizeBlocks | ||
| ? spawn('/bin/sh', ['-c', `ulimit -f ${fileSizeBlocks} && exec "$0" "$@"`, process.execPath, ...argv], { cwd: tmp, env }) |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…siness name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory is named by its verified name POST /chat/fetchHistory does not exist (404), and an ON_DEMAND history message with no push name carries its sender's number, not the verifiedBizName WhatsApp sent with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in; history names a business by its verified name A consumer that stored a WhatsApp Business template before it could read one holds an empty row, and the phone is the only place the text is left. The endpoint passes count, the anchor key and its time to Baileys' fetchMessageHistory and answers the request id; the phone's ON_DEMAND batch arrives as messages.set. A history message with no push name now carries verifiedBizName before falling back to the sender's number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /chat/fetchVerifiedName does not exist (404). ESHUV's on-demand history came back with neither a push name nor verifiedBizName, so the name WhatsApp certifies has to be asked for (usync business/verified_name). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s for a business A usync query with a business protocol asking for verified_name (Baileys rc14 has none), decoding the VerifiedNameCertificate WhatsApp answers; null when the account has none. ESHUV's re-sent history carried no name, so Circles asks for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libsignal prints whole SessionEntry objects with console.info and
console.warn when a session closes, opens or is dropped ("Closing
session:", "Removing old closed session:", "Session already closed").
Node prints them in full: the private ratchet key, root key, chain keys,
base key and the contact's identity key, as <Buffer ...>. They are not
Evolution's logger, so LOG_LEVEL=ERROR,WARN never sees them, and on a busy
account they reach stdout every few seconds.
The test re-keys two real libsignal parties as WhatsApp contacts do (a new
prekey bundle while a session is open) and asserts no key byte, no field
name and no dump is printed, while the one-line "Closing open session in
favor of incoming prekey bundle" still is. quietSignalSessionLogs is added
here as an empty seam so the test fails on behaviour, not on an import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…G_LEVEL At startup every console method is wrapped once. A call that carries a libsignal SessionEntry (an object with _chains and indexInfo, whichever copy of libsignal made it) prints with the session replaced by "[signal session]", and only when LOG_LEVEL has the call's level: console.info is INFO, so "Closing session:", "Opening session:" and "Removing old closed session:" print nothing at ERROR,WARN, and the rare "Session already closed" warning prints without the dump. Every other call, Evolution's own logger and libsignal's one-line "Closing open session in favor of incoming prekey bundle" included, goes through untouched. Installed after Sentry, so its console breadcrumbs see the same lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…for good On 2026-10-29 a Circles number stayed down for 9 hours: its reconnect built a WebSocket through a proxy exit that accepted the TCP connection and never answered CONNECT. Nothing had a clock running: ws arms its handshakeTimeout only once https-proxy-agent hands it a socket (after the proxy answers), and Baileys' connectTimeoutMs and keepalive start only once the WebSocket is open. No close ever came, so the reconnect a close starts never ran. The test connects the real Baileys, ws and https-proxy-agent through a local TCP server that accepts and never writes a byte, and asserts the socket is ended as timed out (408) 45s in, lets go of the proxy connection, and reconnects on the backoff, the reconnect held to the same deadline; and that a pending logout's connect is retried the same way. It fails on the code as it is: the socket is still connecting. The fake-socket cases guard what the fix must not do (end a socket whose WebSocket opened, such as one showing a QR; end a replaced socket, or one of a deleted instance; end again a socket that closed on its own); they pass before the fix, as they should. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ket opens The proxy that never answers now reads what it gets (it saw no EOF while it never read, so a released connection looked held). It is what tells whether the abandoned connect lets go of the connection to the proxy: https-proxy-agent waits for the CONNECT answer with no limit, and an aborted request does not end that wait. fakeSocket's WebSocket now opens once the caller has attached to it, as a socket's does when WhatsApp is reachable; `wsOpens: false` models the exit that never lets it open, which the deadline cases use. Without it, every test that waits out the reconnect backoff on a fake socket would see the deadline end a socket that stands for a healthy one. Still red on the code as it is: no deadline, no release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnected Until the WebSocket opens, nothing could close a socket: ws arms its handshakeTimeout only once the proxy has answered CONNECT, and Baileys starts connectTimeoutMs and the keepalive only once the WebSocket is open. Behind an exit that accepts the connection and never answers, a socket waited in 'connecting' for good, with no close to reconnect (2026-10-29: a number down for 9 hours). createClient now gives each socket 45s (connectTimeoutMs and a margin) for its WebSocket to open. Past it, the socket is ended as timed out (408), and its close is handled as any other: the reconnect backoff, or, while a logout is pending, the next attempt to deliver it. The deadline goes once the WebSocket opens (Baileys' own timers take over, so a QR waiting to be scanned is left alone), once the socket closes, and when the socket is let go of (replaced, or the instance removed). The HTTP proxy agent now lets go of its connection to the proxy when the request is given up before the proxy answers CONNECT (an abort signal on that connection, fired by the request's abort or close). Without it, every attempt ended at the deadline left a connection to the silent exit open, one more a minute for as long as the outage lasted. An https: proxy (TLS to the proxy itself) is not covered; a SOCKS proxy already gives up after socks' own 30s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y connection Every attempt fetches the WhatsApp Web version through the proxy before building the socket. Behind an exit that never answers CONNECT, axios gives up after 10s with req.destroy(), which (before the agent has a socket) emits neither 'abort' nor 'close', so the agent's release never fires and each attempt leaves one connection at the proxy (found by Astra's review). The real-path case now runs the real version fetch through the silent proxy and asserts each attempt holds only its WebSocket's connection there once the fetch has given up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oxy too The release listened for the request's 'abort' and 'close'. ws aborts its handshake with abort(), but axios cancels with destroy(), and before the agent hands over a socket destroy() emits neither: the version fetch given up at its 10s kept its connection at the proxy, one per attempt. The agent now wraps the request's destroy() for the length of the CONNECT (abort() goes through it too). The comment no longer says an https: proxy is not covered: the selector never accepted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and a given-up CONNECT lets go of the proxy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /chat/streamMediaMessage answers the decrypted file as it arrives (application/octet-stream, the media's type in x-media-mimetype), with the first bytes out before WhatsApp has sent the last. A failure before the first byte answers getBase64FromMediaMessage's 400, reupload and all; one after it cuts the answer short. Red: there is no such route. getBase64FromMediaMessage holds the file, its base64 and the JSON around it, about three times the file per download, on a server that carries every number: a 200 MB recording cannot come through it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iaMessage) The same download as getBase64FromMediaMessage (the instance's proxy, the phone asked to re-upload an expired file unless reupload: false, Evolution's own fallback), answered as the decrypted bytes themselves: application/octet-stream, the media's type in x-media-mimetype. A failure before the first byte is the same 400, reupload included; one after it cuts the answer short. Not Baileys' own stream: downloadEncryptedContent joins the CDN's body to its decryption with .pipe(), which does not forward errors, so a download that broke off midway left the stream waiting for good and the CDN's error unhandled. utils/media-stream.ts does the same download with pipeline() and checks the MAC WhatsApp appends, so the stream ends only when the file is whole. The red test's own wait was 5000 ms, which the file's setTimeout spy (there to shorten Evolution's 5 s fallback) turned into 0: it now waits 4000 ms. The streamed download is also covered behind an HTTP and a SOCKS5 proxy (test/proxy/media-download.test.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…umer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages.undecryptable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ups, every retry, and not for an unlisted decrypt error Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, never for peer messages or ignored groups Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s with the same id are one trace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The whole fork as one reviewable diff:
fork/2.3.7against the untouched Evolution API 2.3.7 release (release/2.3.7, at the2.3.7tag). This PR is a review view and will not be merged.test:commit that fails, then the commit that makes it pass; the Commits tab and the Checks on each commit show the red and the green.fork-2.3.7-2026-09-29is a fixed snapshot; this PR keeps following the branch.Unofficial fork, not endorsed by Evolution Foundation. The license and NOTICE are kept, and the user interface and brand assets are unchanged.
🤖 Generated with Claude Code