Skip to content

v0.14.5

Choose a tag to compare

@rmyndharis rmyndharis released this 08 Aug 01:40
· 25 commits to main since this release

Added

  • Auto-reject calls can be turned on from the dashboard — the session detail panel now carries a toggle wired to the config endpoint, so the setting no longer requires an API call to reach, and it states that call.received still fires and that no restart is needed.
  • Session config can now be changed without re-linking the accountPATCH /api/sessions/{id}/config sets autoRejectCalls, maxReconnectAttempts and reconnectBaseDelay on a session that is already running, where all three were previously fixed at creation and turning one on meant creating a new session and scanning the QR again.

Fixed

  • The tests that lock the channel-refusal contract now use a shape the engine can actually produce — they built the refusal with the numeric code an IQ error carries, which that code path never emits, so they exercised the wrong branch of the classifier and stayed green through a release in which channel refusals regressed from 403 to an opaque 500.

  • Four response schemas published values the API cannot emit — search results documented direction as inbound/outbound while every hit carries incoming/outgoing, so a client validating against the contract would have rejected all of them; audit entries documented warning where the code writes warn; the search example named a provider id that does not exist; and the data export omitted skippedTables and omittedInlineMedia — the two fields that say the archive is truncated. Group detail also advertised a participantsCount and an isAdmin that only the group LIST returns.

  • The published image now actually carries the app-state resync fix — the patcher that bounds the loop was wired into the install hook but never added to the two hand-written lists in the Dockerfile, so it was absent from the production stage, the hook's existence check skipped it without a word, and every image shipped unpatched. It is copied and run fatally now, and a derived check fails the build if a patcher is ever added to one list and not the other.

  • A channel refusal answers 403 again, not a bare 500 — narrowing the refusal classifier in the same release fixed the IQ error channel and silently de-classified the other one: WhatsApp's w:mex surface reports a refusal as a GraphQL error inside a successful IQ, so it never carries the numeric code the classifier now reads, and deleting or muting a channel the account does not own regressed from 403 to an opaque 500. The two error channels are decoded separately now; a dead socket and an unanswered query still do not read as refusals.

  • A profile picture lookup that WhatsApp never answered no longer reads as "this contact has no picture" — the route answered 200 with url: null after a silent minute, which is the same answer a contact without a picture gives, so a caller had no way to tell them apart; it now answers 503. Passing the engine's own timeout would not have been enough on its own, because the resulting error landed in the catch that produces that same null. The batch lookup is unchanged: a per-id failure there is documented as null and stays that way.

  • The last fifteen writes that reported success without confirmation now report it — the addressbook saves, block and unblock, the archive/unread/clear/delete chat actions, delete-for-me and star, the four label writes and a call rejection all go through an engine call that discards its own result, so each answered 200 whether WhatsApp applied the change or never replied; a rejected call was the sharpest case, since the gateway also published a rejected outcome for a rejection nothing had acknowledged. All now share the request budget and answer 503 when no confirmation arrives.

  • A dead connection is no longer reported as a bad invite code or a permissions refusal — joining by invite and updating participants both fail without ever entering their catch, because the engine resolves an unanswered query rather than rejecting it: the join landed on the same "invite code may be invalid" 400 as a genuinely bad code, and a participant update on the same 403 as a missing admin right. Both now share the request budget and answer 503; an answered-but-empty result still means what it meant.

  • Marking a chat read no longer answers a bare 500 when WhatsApp stays silent — the read receipt reaches a library helper that destructures the query result, so an unanswered one threw a raw TypeError that nothing could classify; it now shares the same 30-second budget and answers 503. The media send path fails the same way and is deliberately left alone: a send is not repeatable, and 503 is a status the SDKs retry, so making it one would trade an opaque failure for duplicate messages.

  • Channel operations no longer answer a bare 500 when WhatsApp stays silent — every one goes through a query helper that reports an absent answer as a malformed one, which the refusal classifier cannot place, so the raw error escaped with its message discarded; the channel lookup, invite lookup, subscribe, unsubscribe, delete and mute/unmute now share the same 30-second budget and answer 503. Creating a channel is deliberately left unbounded for the same reason as creating a group — it is non-idempotent, and 503 is a status the Go SDK retries for POST.

  • The three group-metadata reads no longer answer a bare 500 when WhatsApp stays silentextractGroupMetadata throws a Boom whose data is normalised to null, so the refusal classifier cannot place it and the raw error escaped with its message discarded; GET /groups/{id} and GET /groups/join-info now spend the same 30-second budget as the rest and answer 503, and creating a group maps a genuine WhatsApp refusal to 403 instead of the same bare 500. Group creation is deliberately left unbounded: it is the one non-idempotent call, and 503 is a status the Go SDK retries for POST, which could leave duplicate groups behind.

  • A group list WhatsApp never answered is no longer served as "you are in no groups"groupFetchAllParticipating fills its result only behind an if (groupsChild), so an unanswered query returns the same empty object an account with no groups produces, and GET /sessions/{id}/groups answered 200 with an empty array after a silent minute; there is nothing in the value to tell the two apart, so the read now shares the same 30-second budget as the group writes and answers 503 instead.

  • ⚠️ Breaking (behavior). A dead socket is no longer reported as a permissions problem — the helper that decides whether a Baileys failure was a server refusal or a transport death guarded on data !== undefined, but Boom's constructor defaults data to null, so the guard matched every Boom ever thrown and a Connection Closed (428) was classified as a 4xx refusal: the group and channel writes that go through that helper answered 403 admin rights or permissions may be missing, joining by invite answered 400 invalid invite code, and reading invite info answered 404, all for a socket that was simply down. Transport failures now propagate as a 5xx instead — a change of status code on the gateway surface for those cases. The three profile writes were never affected: they do not go through that helper. The tests that were meant to cover this passed because their fixture was a bare Error rather than a real Boom, and now use one.
    Migration. If you branch on the status code from the Baileys group/channel writes, POST /groups/join, or GET /groups/join-info, a 403/400/404 there no longer covers the case where the connection was simply down — that now arrives as a 5xx. Treat 5xx as retryable transport failure and keep your existing 4xx handling for genuine refusals; a caller that retried on 403 to work around this should stop, and one that surfaced 400 invalid invite code to end users will now correctly report an outage instead.

  • Twelve group and profile writes no longer report success for a change WhatsApp never confirmedgroupLeave, the subject/description/settings/picture/member-add-mode/disappearing-timer writes and the three profile writes all resolve through a Baileys call that awaits its query and then discards the result, and query() swallows its own timeout and resolves rather than throwing, so leaving a group you are still in and locking a group that stayed open both answered 200 done; each now has a deadline of its own and answers 503 when no confirmation arrives, which does mean a genuinely slow write that lands just after the deadline is reported as unconfirmed.

  • A post-connect app-state resync can no longer spin for the life of the session — Baileys' resyncAppState loops until every collection reports no more patches, but every exit from that loop, including the attempt counter its own comment introduces to "ensure we don't enter a loop that cannot be exited from", sits inside a for…in over the decoded patches; an unanswered query decodes to an empty object, so nothing was removed, nothing was counted, and the walk re-asked every sixty seconds until the socket closed. A postinstall patch ends the walk when the decode is empty, and fails the install rather than silently skipping if the upstream shape moves.

  • A number check no longer answers "not on WhatsApp" when WhatsApp simply did not answer — Baileys' onWhatsApp has no else branch after its result check, so an unanswered lookup resolved undefined, which the adapter folded into the same null a genuine miss produces; GET /contacts/check/{number} then reported exists: false and checkNumberExists returned false — a confident verdict about a number the gateway had heard nothing about, on the one route the send documentation tells callers to trust before sending. An empty result is still a real answer and still reports false; only the absent one now raises 503.

  • A group invite code now says why it could not be read — fetching one is admin-only, but the groups list returns every group the account belongs to whatever its role, so the request lands on ids the caller was just handed: Baileys let WhatsApp's refusal escape as a bare 500 (13 of 16 groups on the test account) while whatsapp-web.js stringified its missing result into {"inviteCode":"undefined"} and the link https://chat.whatsapp.com/undefined behind a 200 (53 of 101). Both engines now answer 403, the same status the group writes have answered since they were fixed, and an unanswered query answers 503 instead of the codeless link https://chat.whatsapp.com/.

  • A catalog read no longer stalls for a minute and then reports an empty catalog — Baileys answers an unanswered query with undefined rather than an error and its catalog parsers are null-safe, so a request WhatsApp never replied to was indistinguishable from a business with no products: GET /catalog and /catalog/products returned 200 with nothing in them after a silent 60 seconds, and send-product reported 404 Product not found for a product it had simply never heard about. The walk now spends one 30-second budget across every page and answers 503, and a server that repeats a page cursor instead of advancing no longer spins the walk until the process exhausts its heap.

  • A benign whatsapp-web.js rejection no longer reads like a crash — the same un-awaited framenavigated re-injection that already logs at WARN when the page goes away was logged at ERROR in its other shape, where the navigation lands before WhatsApp Web has defined its module registry; it appears at the first boot after the pinned WhatsApp Web build moves under a warm profile, and the session reaches ready unaided.

  • A slow whatsapp-web.js attach is no longer mistaken for a dead one and reloaded out from under itself — the event-bridge self-heal could fire on the first reconcile tick, two seconds after authenticated, but whatsapp-web.js clears eventsAttached in its constructor and sets it only once attachEventListeners resolves, with LoadUtils, a poll of up to thirty seconds for window.WWebJS, ClientInfo and InterfaceController in between, so a false flag is the ordinary reading for most of a minute on a loaded host. The reload then navigated the page out from under the injection still running on it, whose re-entry died at getWWebVersion before re-exposing any of the bridge, and the one-shot guard blocked a second attempt, so the session rode out the readiness deadline and was marked failed — credentials kept, but no way back without a restart. The reload now waits out the upstream attach budget first, which still leaves it half the deadline to reinject when one is genuinely warranted.

  • A data export now reports the media it had to leave behind — an over-budget attachment is replaced with the same omitted marker that media skipped on the way in gets, so a truncated backup was indistinguishable from a complete one both on inspection and on restore; the response now carries omittedInlineMedia alongside skippedTables, and the dashboard warns after a download that dropped anything.

  • A node that has observed the loss of its session lease no longer writes FAILED for that session — engine callbacks were fenced only by a local liveness check, so between a heartbeat detecting the loss and the teardown it schedules, a dying generation could park a row a peer already owned in FAILED, which is excluded from both the boot reset and the takeover sweep by design and so left the session outside every automatic recovery path on every node until an operator restarted it by hand. The fence engages from the heartbeat tick that detects the loss, not from the moment the lease lapses, so a window remains.

  • Thirteen settings that did nothing under Docker now take effect — Compose forwards environment explicitly and has no env_file, so BAILEYS_MARK_ONLINE_ON_CONNECT, BAILEYS_SYNC_FULL_HISTORY, WEBHOOK_CONTACT_DETAILS, ALLOW_UNSIGNED_INGRESS, STORE_EPHEMERAL_MESSAGES, RESOLVE_LID_TO_PHONE, SIMULATE_TYPING, MCP_ENABLED, SEARCH_ENABLED, SERVE_DASHBOARD, CACHE_ENABLED, DATABASE_LOGGING and MAIN_DATABASE_SYNCHRONIZE never reached the container however the operator's .env was written — the MCP server could not be enabled at all, SEARCH_ENABLED=false did not disable the search route it documents, and BAILEYS_MARK_ONLINE_ON_CONNECT left the paired phone's notifications suppressed for as long as the gateway stayed connected.

Changed

  • Twenty-eight routes now document the 503 they gained in this release, and three document the 501 they always answer on one engine — every route that reaches an engine call with a request budget can time out, but only ten said so, so a client generated from the contract had no branch for it on the rest and would have reported a gateway fault as an unexpected error; the channel list and channel-message reads refuse on Baileys and subscribe-by-invite refuses on whatsapp-web.js, none of which was stated. The batch avatar lookup's description also said three concurrent engine lookups where the code runs five.
  • Every remaining module now publishes the shape of what it returns — health, profile, statistics, media, settings, audit, calls, metrics and search were the last nine with no response schema anywhere, so three quarters of the API had nothing for the contract gate to diff; /api/metrics is now typed as the Prometheus text it serves rather than implying JSON, and PUT /api/settings keeps no success schema because it always answers 501 by design.
  • The labels, channels and status endpoints now publish the shape of what they return — all twenty-four carried a status and a sentence but no schema, so the committed OpenAPI snapshot had nothing to diff for three whole modules; the status media route is now typed as the binary stream it is rather than implying JSON, and a status timestamp is documented as the ISO-8601 string it serialises to rather than the Date the engine hands over.
  • The catalog endpoints now publish the shape of what they return, including one that disagrees with the rest of the APIPOST /messages/send-product answers {id, timestamp} while every send served by the message module answers {messageId, timestamp} for the same value, because this route passes the engine result through unmapped; the schema records the field the code actually sends rather than the one the neighbouring routes use. send-catalog is left with no success schema on purpose: no engine can send a catalog link, so a 2xx there would document a response that cannot occur.
  • The twelve infrastructure endpoints now publish the shape of what they return, and one of them no longer describes the wrong thingGET /infra/storage/export was documented as a "Tar.gz archive stream" but answers JSON pointing at an archive it wrote under data/exports/, so a client following the contract would have waited for a binary body that never comes; the save and restart payloads also now state that a failed config write still answers 200 with saved: false, which is the flag a caller has to read.
  • The ten contacts endpoints now publish the shape of what they return — they documented a status and a sentence but no schema, so the committed OpenAPI snapshot had nothing to diff for any of them and a change to a response body could reach clients without the contract gate noticing; GET /contacts/{contactId}/phone returning null for an unresolvable id, and the batch picture lookup answering null per id rather than failing, are now stated rather than discovered by calling.
  • The eighteen group endpoints now publish the shape of what they return — the whole module documented statuses and prose but not one response schema, so the committed OpenAPI snapshot had nothing to diff for any of it; the per-participant results array that an add/remove/promote/demote returns, and the fact that a partial refusal is reported inside a 200 rather than as an error, are now in the contract instead of only in a sentence.
  • Fourteen more boolean environment variables are now validated at boot — they are read with a bare === 'true' / !== 'false' comparison, so a spelling like DATABASE_SSL=require silently configured the opposite of what was asked for; DATABASE_SSL in particular read as OFF and sent credentials in plaintext to a server the operator believed was TLS-protected. Only true/false (or blank) are accepted now, so a deployment using another spelling will fail startup until it is corrected. MCP_READONLY and PUPPETEER_HEADLESS stay tolerant deliberately — both fail toward the safe state.
  • The API description now documents the two statuses middleware returns before routing415 for a compressed request body and 503 with Retry-After when too much body data is already in flight, neither of which appeared anywhere in the OpenAPI document despite applying to every operation.
  • The Helm chart now states the reason replicaCount must stay 1 that actually applies today — the warning described two pods corrupting shared session auth, which a session lease and per-pod volumes already prevent, so an operator acting on it would mitigate the wrong thing.

Documentation

  • The webhook troubleshooting runbook told operators there was no webhook-delivery log API and to grep container logs instead; that was true when written but GET /api/webhooks/delivery-failures shipped four days later, so the one page reached when a webhook is silent denied the endpoint exists. It now carries the real call, names the fields that gate dispatch (active, events, filters), and notes that lastTriggeredAt is never set by the Test button.
  • The n8n trigger event table advertised call.accepted / call.rejected / call.missed with no engine caveat, so a whatsapp-web.js user could build a missed-call workflow on events that engine never emits; they are now marked Baileys only, and the troubleshooting section names n8n's test-versus-production webhook URL, which silently delivers a single event and then stops.