Added
- Autoreply rules — per-session single-message autoreplies under
/api/sessions/:id/automation-rules; conditions use the webhook filter format, the reply goes through the normal send path, andfromMe/freshness/per-chat-cooldown guards bound reply loops. - Message & chat management — pin/unpin and star/unstar messages, archive/unarchive chats, clear a chat without deleting it, and vote on polls (whatsapp-web.js).
- Contacts, groups & channels — save/edit/remove addressbook contacts; read/set/remove a group picture;
memberAddModegroup setting; preview a group from its invite code before joining; create/delete/mute channels. - Labels — create, rename, recolour and delete labels; eight label agent tools for MCP (four read-only, four write).
- Presence & calls — subscribe to presence (
presence.update, online/typing) and receive call-outcome events (call.accepted,call.rejected,call.missed). - Send options —
linkPreviewtoggle onsend-text, plus a caller-suppliedcustomLinkPreviewon Baileys. - Media & status — server-side media conversion (audio→Ogg/Opus, video→MP4) via
ffmpeg(MEDIA_CONVERSION_ENABLED); post an audio status as a voice note; archive chat media to the file store and fetch it back after delivery (CHAT_MEDIA_ARCHIVE_ENABLED). - Opt-in send pacing (
SEND_PACING_ENABLED) — warm-up ramp, daily caps, a failure breaker, and a cold-reachout budget that also bounds group participant adds; enforcement is recorded in the audit log. - Account restrictions surfaced — WhatsApp-imposed restrictions appear on the session (API +
session.restrictionwebhook + dashboard badge) instead of a generic error. - SDK coverage for the new surface — the JavaScript, Python, Go, Java and PHP SDKs all gained this release's new calls: pin/star a message, poll voting, archive and clear a chat, labels, channels, presence subscription, group picture / join-info / member-add mode, addressbook contacts, server-side media conversion and voice status. Autoreply rules are REST-only for now.
- Horizontal-scaling groundwork (opt-in) — sessions record their owning process via a renewed lease so two replicas cannot both start one; a node only reaps the in-flight bulk batches of sessions it may claim, and a data import reports the sessions another node is running; a dead node's sessions are adopted automatically once the lease lapses (
SESSION_TAKEOVER_SWEEP_MS); session-scoped requests are forwarded to the owning node when each node setsNODE_URL; and WebSocket events fan out across replicas whenREDIS_ENABLED=true.
Changed
- Built under the full TypeScript
strictfamily, with per-module test-coverage floors across the codebase. - The official Docker image now installs
ffmpeg(~210 MB larger, measured with--no-install-recommends). It backs the opt-in media-conversion endpoints and is installed unconditionally, so the binary is present even thoughMEDIA_CONVERSION_ENABLEDstill defaults to off. It is the Debian package rather than a bundled static build so codec CVEs arrive through the same security stream as the rest of the image. session.restrictionis now socket-subscribable as well as webhook-delivered, and the dashboard session card picks up a restriction (or its lift) live instead of only on a page reload.- ⚠️ Breaking (behavior). Eager status backfill on session ready is now opt-in (
STATUS_SEED_ON_READY, default off): the immediatestatus@broadcastread could make some freshly paired whatsapp-web.js accounts lose the companion at WhatsApp Web's first scheduled reload. Statuses posted before a session connects are therefore no longer backfilled unless you setSTATUS_SEED_ON_READY=true; live status events are unaffected either way. Thanks @duckvhuynh. - ⚠️ Breaking (behavior). Link previews are opt-in on the Baileys engine. Sends carrying a URL previously produced a preview card because the engine's own generator ran by default; they now go out without one unless the request passes
linkPreview: true(or acustomLinkPreview). This restores the documented engine default and removes a blocking outbound fetch from every URL-bearing send — which cost a bulk campaign minutes — but recipients of an unchanged integration will see plain links where a preview card used to appear.
Fixed
- Status posting on the whatsapp-web.js engine — current WhatsApp Web had broken text and media status outright; the postinstall patcher restores both. Text, image, video and voice status post again.
- whatsapp-web.js sessions could report "ready" with a dead inbound pipeline after a warm restart, silently dropping incoming messages. The adapter now refuses to promote a session whose event bridge never attached, reloading the page once and failing loudly (keeping credentials) if it stays dead.
- Baileys delete-chat, mark-unread and delete-for-me silently did nothing on individual (1:1) chats — the neutral id was not folded to the engine form used as the app-state key.
- Voice notes on the Baileys engine now carry a waveform.
- The webhook producer enqueues idempotently, and the bundled Redis is pinned
--maxmemory-policy noevictionso queued jobs are not silently dropped. - A media-storage root the app cannot write to is caught at boot with a clear error, instead of failing on the first write (#1066).
- The
postinstallhook no longer aborts withEALLOWSCRIPTSunder npm 11 when the user's.npmrcsetsallow-scripts=true: npm exports that asnpm_config_allow_scripts, which npm 11 refuses in the nested dashboard install. The hook now strips it from the environment passed to each nested step. Thanks @configurowebmax. - The bundled
docker-compose.ymlnow forwards theSEND_PACING_*,MEDIA_CONVERSION_*/FFMPEG_PATHand session-ownership (NODE_ID,NODE_URL, lease/sweep) variables — previously these features could not be enabled from.envin a compose deployment at all. - The four label write tools (
LabelUpsert,LabelDelete,LabelAddToChat,LabelRemoveFromChat) answeredInternal errorover MCP even though the write had succeeded on WhatsApp, prompting agents to retry a completed operation; they now return{ success: true }. - Boot validation covers the lease and routing knobs. A heartbeat that is not comfortably under half the lease TTL (renewals landing after the claim lapsed, so peers adopt sessions from a healthy node), a
NODE_URLthat is scheme-less or carries embedded credentials, a non-integerAUTOMATION_MAX_PER_SESSION, and a non-positive media-conversion knob are now boot errors instead of silent fallbacks or a 500 on the first forward. Forwarded responses also relay the owner'sRetry-After/X-RateLimit-*, and an unusable owner URL answers 503 rather than 500. - Autoreply rules gain a per-session cap (
AUTOMATION_MAX_PER_SESSION, default 32,0= unlimited), mirroring the webhook fan-out cap's shape (WEBHOOK_MAX_PER_SESSION, default 16): every inbound message is evaluated against every rule of its session. - The cold-reachout probe recognises a bare phone number, so a known contact passed without a JID suffix is no longer charged as a stranger; a voice status accepts
backgroundColor; and a graceful shutdown no longer logs a handful of Redis unsubscribe errors. - Security (multi-node routing only): the session forwarder could be aimed at any origin. HTTP/1.1's absolute-form request target (
GET http://elsewhere/api/sessions/x) is matched by the router and left verbatim in the request URL, where resolving it against the owner's address discards that address — an authenticated caller could make a node forward their request, API key attached, to an origin of their choosing and read the response. The forward target is now rebuilt from the owner's origin plus the request's path and query, so the destination cannot be influenced at all. Deployments withoutNODE_URL(the default) were never affected: the forwarder is inert there. stopanddeleteare fenced against a session a live peer is running. Onlystartwas claim-checked, and neither of those two needs a local engine, so a request landing on the wrong node — routine when session ownership is configured but request routing is not — wrotedisconnectedover a peer's live session or deleted its row and credentials while the peer's engine kept running. Both now answer 409; a lapsed claim still proceeds, since taking over is what the claim rule allows.- Multi-node ownership races closed: a
stoplanding while astartis mid-launch no longer hands back the claim under a live engine (which left the engine unclaimed and startable a second time elsewhere), a failed start no longer pins its session to that node forever, and a deliberate teardown of a session whose crashed owner's lease had lapsed now really leaves it down instead of being re-adopted by the takeover sweep. Successful forwarded requests also stop logging a spurious error. - The send breaker only counts failures that reached WhatsApp. Client-fault and engine-state errors raised inside the send call — a blocked media URL, a capability the engine lacks, a disconnected socket, a malformed status post — no longer accumulate toward it, so a client sending bad requests can no longer 429 every send on a healthy session for the cooldown. Applied to single sends, bulk batches and status posts alike.
send-stickerwith a video mimetype works in the official image. whatsapp-web.js converts video to animated WebP throughffmpeg, which the image did not ship, so a documented-as-supported call always failed there; the image now carries the binary.- Backup/restore covers
automation_rules. The table was in neither the export nor the import while the restore's session wipe cascade-deletes it, so a documented backup→restore silently destroyed every autoreply rule. - Media conversion answers 400 (not 500) for a blocked, unreachable or oversized input URL;
POST /api/sessions/:sessionId/channels/:channelId/muterefuses a non-channel id instead of muting an ordinary chat forever; listing a label's chats and previewing an unknown group invite answer 404 instead of 500 on whatsapp-web.js; a refused disappearing-message change answers 403; voice-status media is served as audio rather than octet-stream;PUT /labels/:idtreats explicitnullfields as an empty body; and an addressbook write with a bare phone number is qualified before it reaches Baileys. - Small correctness batch: the cold-reachout history probe matches both user-id spellings (a known contact addressed the other way no longer reads as cold), an expired account-restriction stops badging the session, ending a ringing call clears its live handle,
PUT /labels/:idrefuses an empty body, addressbook writes refuse group/newsletter/broadcast ids (not just@lid), and the channel endpoints' OpenAPI docs state the real 403 refusal status instead of 422. - SDKs: the Go SDK encodes a nil poll-vote
optionsas[](clearing a vote now works from the zero value), the JavaGroupSettingsandSendVoiceStatusRequestrecords keep back-compat constructors after gaining a field, and all five SDKs expose the voice-statusbackgroundColorthe server accepts. - The dashboard status viewer now plays a voice status with an audio player instead of rendering it as a broken image, the JS SDK's
StatusRecord.typeincludes'voice', and the SDK README's method tables and three misplaced doc comments were brought back in line with the actual surface. - Fetching a status's media that S3 retention already removed answers 404 instead of 500 (the local-storage miss already did), and server-side media conversion is bounded to
MEDIA_CONVERSION_CONCURRENCY(default 2) concurrentffmpegprocesses with a short queue — saturation answers 503 instead of stacking processes. - Baileys message-action targeting: star/pin/unpin/react/delete now verify the stored message belongs to the requested chat (a mismatched pair answered success while writing under the wrong conversation), pin/unpin/react/delete resolve LID-migrated contacts like every other send, and tagging/untagging a chat's label folds the neutral id to the engine form so the write lands on the real chat instead of a phantom index.
- whatsapp-web.js raw-id extraction hardening: every raw WhatsApp Web id is now read through one helper that accepts the renamed property, so a minified WA Web build no longer breaks id-bearing reads. Listing a label's chats no longer 500s when a labelled chat was deleted; the channel list, channel creation and group-invite preview stop returning ids as the literal string
"undefined"or turning a valid invite preview into a false 404; the number-registration check (GET /api/sessions/:sessionId/contacts/check/:number) stops reporting every number as unregistered; and group participants, the group owner and the "am I admin" comparison resolve again instead of yielding"undefined"ids. - Baileys refusals now answer their documented statuses instead of 500: an invalid/expired group invite previews as 404, admin-refused group writes (subject, description, announce/locked, picture, member-add mode) and refused channel writes (create/delete/mute) answer 403 — matching the whatsapp-web.js engine. Transport failures still propagate unchanged.
- Multi-node request routing hardening: forwarded requests now carry the client address in
x-forwarded-for(soallowedIpskeys and per-IP throttling see the real client once peer nodes are listed inTRUSTED_PROXIES), a malformed session id no longer 500s on Postgres in routed mode, and the client-settable forwarded marker is verified — a marked request landing on a live non-owner answers 409 instead of executing there. - Send pacing accounting: forwards now pass through the cold-reachout gate (they were ungated while still draining the budget), replying to someone who wrote first today no longer spends the cold budget, and a pacing 429 inside a bulk batch is recorded as
SEND_PACING_LIMITEDinstead of a genericSEND_FAILED. - Multi-node: a session claim could outlive its engine and pin the session to one node forever. A failed start, a logout, a force-kill or an exhausted reconnect kept the ownership claim, and the heartbeat renewed it indefinitely — the session could not be started on any peer and the takeover sweep never saw it. Claims are now released on every teardown path, and the heartbeat only renews claims that still cover a live engine, an in-flight start or a pending reconnect. Starting a nonexistent session also answers 404 again instead of a misleading 409 "running on another node".
- Corrected column names in
docs/05-database-design.md. - Documentation refresh: capability-matrix and MCP tool counts, the
sessionsownership columns and theautomation_rulestable in the database doc (both now covered by the schema-accuracy gate), the socket-subscribable event list, and precise wording for send-pacing scope and failover engine-overlap.
Security
- Resolved five advisories in the production dependency tree via transitive bumps (
brace-expansion,fast-uri,hono,ip-address,socket.io-parser); no direct dependency changed.