Releases: rmyndharis/OpenWA
Release list
v0.8.14
Added
-
Plugin search providers (host→plugin search RPC). A sandboxed plugin can now register as a
SearchProviderby callingctx.registerSearchProvider(handler)(e.g.ctx.registerSearchProvider(async (query) => …)) from its worker. The host routesGET /api/searchqueries to the plugin over a new correlatedsearch/search-resultwire protocol (mirroring the existing hook/webhook/health-check bridges), so a plugin owns all of its vendor-specific query logic (Meilisearch, Elasticsearch, Typesense, …) while the core stays backend-agnostic — adding a new backend is a plugin, with no core changes. WhenSEARCH_PROVIDER=auto(the default), an enabled plugin provider supersedes the built-in database full-text provider;SEARCH_PROVIDER=builtin-ftskeeps the built-in active;SEARCH_PROVIDER=nonedisables search. Searches are bounded by a 10s timeout and fail fast rather than hanging, and a provider is dropped from the registry when its plugin is disabled. This is Part 1 of the plugin-search work (the query RPC + selection policy); the indexing side uses the existingmessage:persistedhook. -
Search queries are bounded host-side and plugin results are re-scoped.
GET /api/searchnow clampslimit(toSEARCH_LIMIT_MAX) andoffset(toSEARCH_OFFSET_MAX) before dispatching to any provider, so a plugin backend can no longer be asked for an unbounded page; a plugin provider's returned hits are re-filtered host-side against the caller's session scope, mirroring the SQL-enforced built-in provider. The provider'stotalis preserved when no out-of-scope hit was stripped, so pagination is unaffected. [#680] -
Ingress events and delivery failures are now retained.
ingress_eventsandintegration_delivery_failuresare pruned pastINGRESS_RETENTION_DAYS(default 90;<=0disables), matching the existing webhook/audit retention so the tables no longer grow without bound. [#680] -
MCP auth failures are audited and Bull Board login is throttled. MCP authentication failures are now written to the audit log (parity with the REST
ApiKeyGuard), and the Bull Board login endpoint is pre-auth IP-throttled (sharing MCP'sMCP_IP_RATE_LIMIT_*settings) to bound credential-guessing floods. [#680] -
Ingress guardrails. A startup warning now names any unauthenticated (
scheme:'none') ingress route a plugin declares; the documented{id}HMACcontentTemplateplaceholder is now implemented (previously it silently 401'd deliveries that used it). [#680] -
CI now type-checks spec files, and the release gate matches CI. A
tsc --noEmit -p tsconfig.jsonstep full-program type-checks test files (previously invisible to CI, sincetsconfig.build.jsonexcludes specs); the release workflow's test gate now runs the full CI suite (lint + typecheck + jest + e2e + postgres-migration + dashboard) gating both the image and the GitHub Release. [#680] -
Korean (한국어) dashboard locale. The dashboard language picker now includes Korean alongside the existing locales. Thanks @moduvoice. [#679]
Fixed
-
Sending media to a channel (
@newsletter) on the whatsapp-web.js engine now fails fast with501 Not Implementedinstead of a raw500. whatsapp-web.js builds a channel media message and callsmsg.avParams(), a WhatsApp-Web-page method removed in a recent WA Web build, so the send crashed withTypeError: msg.avParams is not a function(upstream wwebjs#201823, unresolved in the current 1.34.7 release). The engine adapter now guards all media sends — image/video/audio/document (which share one send path) and sticker (a separate path that funnels into the same crash for channels) — for a channel recipient and raises a typedChannelMediaNotSupportedError(501) before reaching the broken upstream code. Text→channel still works; only media is affected. The Baileys engine is unaffected. Workaround for affected media-to-channel sends until the upstream is fixed: switch the session to the Baileys engine, or pinWWEBJS_WEB_VERSIONto a cached WA Web build that predates theavParamsremoval. [#673] -
base64media now takes precedence overurlwhen both are provided on a media send. Asend-image/send-video/etc. request carrying both fields previously sent the URL — and because@ValidateIfskipped@IsUrlvalidation onurlwheneverbase64was present, a staleurl(e.g. an example default left in the body, or a client such as the n8n community node that sends both) was fetched unvalidated and could 404, silently shadowing the supplied base64 image.buildMediaInputnow prefersbase64, aligning the send path with the already-base64-first persisted message metadata. Both engine adapters (whatsapp-web.js and Baileys) already guard the remote fetch behind anisHttpUrlcheck, so the change covers both. [#670] -
Fresh Docker Compose dev installs no longer boot-loop with
SQLITE_CANTOPEN..env.exampleshipsDATABASE_NAME=openwa(a PostgreSQL db-name) which, in the SQLite dev compose, became the SQLite file path — SQLite tried to open a bare file on the read-only container rootfs and retried 9× per boot. The dev compose now forwards a blankDATABASE_NAMEdefault (matching production),.env.exampledocuments it as PostgreSQL-only, and env-validation rejects a bare SQLite name with a clear boot error. [#677] [#680] -
DATABASE_TYPE=postgrescombined withDATABASE_SYNCHRONIZE=trueis now rejected at boot. On PostgreSQL the data connection runs migrations every boot, sosynchronizeimmediately dropped the search migration's generatedbody_tscolumn and broke/search(501) on every restart; the misconfigured combination now fails fast. [#680] -
Resolved internal IPs are no longer leaked in SSRF-block error messages. The webhook test response, delivery-failure/DLQ
lastError,webhook:errorhook payloads, and plugin-download errors now show a generic message instead of the resolved internal address (which was a server-side-recon oracle). The full detail is still logged server-side. [#680] -
STORE_EPHEMERAL_MESSAGES=falseis now honored on Baileys history backfill. Previously only the live inbound path enforced the opt-out, so disappearing-message history was silently persisted + indexed on every connect/reconnect despite the operator's opt-out. [#680] -
Plugin archive extraction is bounded and fails clean. Per-entry and aggregate actual extracted bytes are now capped (a lying
size=0header can no longer inflate unboundedly), and a corrupt/oversized archive returns a 400 instead of an uncaught 500. [#680] -
WebSocket auth lifecycle. IP-restricted API keys can now connect from an allowed IP (previously the gateway failed-closed and locked out every IP-restricted key), and a revoked/disabled key's active sockets are now evicted. [#680]
-
Misc hardening. The migration CLI honors
MAIN_DATABASE_NAME;secret-filechmod failures are logged (a world-readable secret file no longer stays silent after a rewrite on a chmod-unsupported FS); the fully-expanded IPv4-mapped-loopback SSRF classifier gap is closed (latent, defense-in-depth); storage file-list/export traversal is now async + bounded (no longer blocks the event loop); the dashboard and all four SDKs warn on a non-localhosthttp://baseUrl. [#680]
v0.8.13
Added
- Dashboard search panel. The Chats page now has a global search bar in its header — type to search messages across all sessions, see results with highlighted snippets in a dropdown, and click a result to jump to that message in its chat (with cross-session navigation and best-effort message scrolling). Includes a scope toggle (all sessions / current session), load-more pagination, and graceful error states (incl. "search unavailable" when search is disabled). The snippet renderer is XSS-safe (escape-then-highlight, never
dangerouslySetInnerHTML). Search UI strings are localized across all 10 supported locales. - SDK search resources. The JavaScript, Python, PHP, and Java SDKs now expose a
searchresource mirroringGET /api/search—client.search.search({ q, sessionId?, chatId?, … })→SearchResults. Typed params and results match the backend contract exactly.
v0.8.12
Fixed
- Debian Chromium SIGTRAP crash in Kubernetes. The
Dockerfilepreviously installed the OS-levelchromiumpackage from Debian 12. When run as a non-root user in strict Docker/Kubernetes environments, this package consistently crashed at startup withCode: nullandTrace/breakpoint trap (core dumped). On amd64 theDockerfilenow downloads Chrome for Testing via Puppeteer during the build phase (avoiding the Debian package's SIGTRAP); arm64 keeps Debian'schromium, since Chrome for Testing publishes no linux-arm64 build. Both resolve to a singlePUPPETEER_EXECUTABLE_PATHsymlink. This resolves the persistent browser launch failures on restricted environments without requiring insecure workarounds like--no-zygoteor disabling Seccomp/AppArmor. Thanks @muhfalihr.
Added
- Global message search across sessions.
GET /api/searchfinds messages across all sessions through an openSearchProvidercontract, with a built-in database full-text provider (PostgreSQLtsvector/GIN, SQLiteFTS5) as the zero-dependency default — no external service required. Search works out of the box on both SQLite and PostgreSQL and survives repeated dialect switching and export/import round-trips; a non-FTS5 SQLite build skips the index gracefully and the route returns501instead of crashing boot. SetSEARCH_ENABLED=falseto disable the route and module entirely (the index is DB-maintained per-write regardless, at negligible in-process cost). Advanced backends (typo-tolerance, CJK word-segmentation, large-scale relevance) will be available as provider plugins. message:persistedplugin hook. A new general extension point fired when a message is durably persisted — on outbound send (fromMessageService) and on inbound receive (fromSessionService, where both engine adapters converge on the single persist) — so plugins can react to persisted messages (e.g. an external search indexer) without coupling to the message/session services. The built-in FTS search provider is DB-synced and does not consume this hook; it exists for plugin providers and general use. Fire-and-forget: a handler error is swallowed and never breaks the send/receive path.- Redis authentication via username. Added support for
REDIS_USERNAMEto configure the Redis cache connection and BullMQ queue connections that require a username. Thanks @muhfalihr. - OpenAPI/Swagger snapshot export + enriched, auth-accurate API docs. The published spec (served at
/api/docsand exportable viascripts/export-openapi.ts) now mirrors the runtime auth model:@Public()routes (/api/health*,/api/infra/health, and the integration ingress wildcard) are marked as not requiring anX-API-Key, matching what theApiKeyGuardactually enforces. Thewebhookeventsenum now advertises the*subscribe-all wildcard the dispatch layer already honors, and request/response schemas are filled in across the catalog, status, stats, integration-instance, and auth endpoints — notably typing the integrationInstanceView(and itsIngressUrlentries) as real schemas so generated clients no longer receive opaque objects. The export script pins a hermetic environment (in-memory SQLite, queue/MCP off) so it is safe to run anywhere without touching a real database or opening a Redis connection.
v0.8.11
Added
- Prometheus counter for terminally-failed webhook deliveries.
/api/metricsnow exposesopenwa_webhook_delivery_failures_total, incremented once per delivery that exhausts all its retries (mirroring the durablewebhook_delivery_failuresdead-letter record, on both the queued and the queue-disabled direct path). It is an in-process monotonic counter — cheap, real-time, and resetting only on restart, which Prometheusrate()/increase()treat as a normal counter reset — so aCOUNT(*)over the retention-pruned failure table isn't queried per scrape. Operators can now alert on webhook failure rate from the scrape instead of tailing the structured log or querying the dead-letter endpoint.
Changed
- Runtime feature flags are centralized in the config layer.
AUTO_START_SESSIONS,STORE_EPHEMERAL_MESSAGES,RESOLVE_LID_TO_PHONE,SIMULATE_TYPING, andSIMULATE_TYPING_MAX_MSnow resolve through a single discoverablefeatures.*namespace onConfigService(backed by onecomputeFeatureFlags()source of truth) instead of ad-hocprocess.envreads scattered across the session and message services. Runtime behavior and defaults are unchanged. The four boolean flags are now validated at boot alongside the existingQUEUE_ENABLED/MCP_ENABLED/SERVE_DASHBOARDchecks —⚠️ behavior change: a deployment booting one of them with a non-canonical value (e.g.SIMULATE_TYPING=1,AUTO_START_SESSIONS=yes) will now fail fast naming the offending key until corrected totrue/false/unset, instead of silently falling back to the default. - Coverage floors added for the session, webhook, and hook-manager modules. Per-directory Jest
coverageThresholdentries now guardsrc/modules/session,src/modules/webhook, andsrc/core/hooks(set just below measured, matching the ratcheted floors already in place for the security, auth, engine-adapter, and integration modules), so a large deletion of these modules' tests fails CI instead of passing under the softer global gate.
Fixed
- Inbound integration (ingress) deliveries now retry with backoff instead of failing on the first error. A queued ingress job was enqueued with no retry policy, so BullMQ ran a single attempt and a transient plugin-handler error (e.g. a 5xx from the sandbox) went straight to the dead-letter table — asymmetric with the webhook queue, which already retries. Ingress jobs now use bounded exponential-backoff retries (default 3 attempts, 5s base delay; configurable via
INGRESS_MAX_ATTEMPTSandINGRESS_RETRY_DELAY_MS), and the dead-letter write still fires exactly once, only after the retries are exhausted.⚠️ Ordering caveat: the per-conversation ordering lock guarantees no two same-conversation dispatches run concurrently, but a retried delivery re-enters the lock after its backoff and can therefore overtake a same-conversation successor that dispatched during the backoff window. Ingress order is best-effort regardless (the provider delivers over unordered HTTP); order-strict plugins must not assume a retried event still arrives in sequence. This trades strict order for throughput — BullMQ releases the worker slot during backoff, where retrying inside the lock would hold it. /infra/statusnow actively probes the databases (SELECT 1) instead of trustingisInitialized. The Infrastructure panel's database tile read onlyDataSource.isInitialized, which staystrueafter a PostgreSQL backend dies (until an explicit.destroy()), so the tile showed the database healthy while it was actually down. The endpoint now runs a short, timeout-boundedSELECT 1on both connections — the same probe/health/readyuses — so a post-init outage is reflected. SQLite is effectively always up, so this only changes behavior for a genuinely-down external PostgreSQL.- The settings panel reports the real docs and base-URL configuration.
GET /settingshardcodedenableDocs: true(ignoringENABLE_SWAGGER, so it claimed the API docs were enabled in production where they are disabled by default) and anhttp://localhost:<port>apiBaseUrl(ignoring the operator's configuredBASE_URL). Both now reflect the real values, andautoReconnectreports the engine's actual default (on) rather than a hardcodedfalsefor a config key that never existed. - A reaction no longer clobbers a message's delivery status.
applyReactionread the full message row, mutated itsreactionsmetadata, then wrote the whole row back — so a delivery ack (SENT→DELIVERED/READ) that committed in the window between that read and write was overwritten with the stale status, permanently regressing the message's state until another ack happened to arrive. The reaction now writes only themetadatacolumn via a scopedUPDATEkeyed on(sessionId, waMessageId), so it can never touchstatus(acks and reactions update disjoint columns). The existing per-message serialization for concurrent reactions is unchanged. PUT /infra/configreturns the real HTTP status for a rejected configuration. A validation failure (an unknown engine type, or a value carrying a newline that would inject an extra env var) was caught and returned as HTTP 200 with{ saved: false }, so a client branching on the status code alone treated a rejected save as success. Such validation errors now surface as their real 4xx (BadRequestException); a genuine persistence fault (e.g. a disk/permission error while writing the env file) still returns{ saved: false }with 200, preserving the dashboard'sbody.savedhandling.- Deleting a session no longer orphans its webhooks, templates, or stored Baileys messages on SQLite.
webhooks,templates, andbaileys_stored_messagesdeclare anON DELETE CASCADEforeign key tosessions, but the defaultdataengine (SQLite) runs withforeign_keysOFF, so that cascade never fired — a session delete removed only the session,messages, andmessage_batchesrows and left the rest behind indefinitely (orphanedwebhooksrows in particular retain their signing secret and any custom headers).delete()now removes all CASCADE-FK child rows explicitly inside the same transaction (children before the parent), which is engine-agnostic — redundant-but-harmless on PostgreSQL, where the real cascade already handles it — and mirrors the ordering the data-restore path already uses. - The
message:sendingmoderation gate andmessage:failednotification now cover every outbound path. Both hooks were wired only into the textsendTextmethod, so a plugin registered onmessage:sending(the canonical pre-send moderation/compliance gate) saw no image/video/audio/document/sticker/location/contact/poll/reply/forward send and no bulk send at all, and amessage:failedplugin saw only text-send failures. Every single sender now passes through a shared pre-send gate (a plugin can block or rewrite the payload) and a shared failure emitter, andBulkMessageServiceruns the same per-message gate (a block fails just that message, honoringstopOnError) and emitsmessage:failedon a failed batch item.⚠️ Behavior change: amessage:sendingplugin now receives — and can block/modify — media, extended, and bulk sends it previously never saw; the hook payload carries atypediscriminator (image/video/poll/reply/… ) so a handler can scope its logic per send type, and itserrorfield is sanitized so an SSRF-blocked media fetch does not expose the resolved internal address to plugins. A moderation block on a bulk item fails just that item (honoringstopOnError) without emittingmessage:failed, matching single-send where a block is a client error, not a delivery failure.message:sentis unchanged (still emitted once from the enginemessage_createpath). - Sibling webhooks subscribed to the same event now get distinct idempotency keys. The per-event idempotency key was derived only from the event and its payload, so two different webhook endpoints registered for the same event on the same session received an identical
X-OpenWA-Idempotency-Key. A receiver sitting behind both (or a shared dedup store) could drop the second endpoint's delivery as a replay of the first. The key is now salted with the destination webhook's id, so each endpoint is dedup'd independently while retries of the same delivery (including the queue-add→direct fallback) keep their stable key. - A session with auto-reconnect turned off no longer reports "reconnection failed after 0 attempts". When a disconnect fired with the reconnect budget set to
0(auto-reconnect disabled), the session was markedFAILEDwith the exhausted-retries message and a count of0— implying a retry loop had run and failed rather than a feature that is simply off. That case now records an explicit "Auto-reconnect is disabled" reason; the genuine exhausted-retries message (with its real attempt count) is unchanged. - A failed inbound integration (ingress) delivery is no longer silently dropped on the inline path. With the queue disabled (or Redis unreachable), an ingress delivery is dispatched inline; if the plugin handler threw, the error was swallowed and the event was stranded — the provider had already received its
202, and the redrive tooling only scans the dead-letter table, which never got a row. The inline path now persists a dead-letter record (the same shape the queued path writes on its final failed attempt), so the event is redrivable. The write is best-effort: a failure to persist it is logged but never turns the202into a5xx(the delivery is already dedup-persisted, so the provider won't re-send). - Plugin instance session bindings are re-derived on startup, so a binding lost while the plugin was momentarily unloaded self-heals. Provisioning a plugin instance mirrors its config into the plugin runtime (per-session config + activation) so an ingress handler resolves the right...
v0.8.10
Added
- PostgreSQL schema selection via
POSTGRES_SCHEMA. OpenWA's tables and the TypeORM migration ledger can now be placed in a dedicated Postgres schema (defaultpublicpreserves historical behavior). SetPOSTGRES_SCHEMAto isolate OpenWA from other apps sharing a database, or to use a managed-Postgres project schema. The schema must already exist (the built-in container creates it; for external Postgres runCREATE SCHEMA <name>;once). SQLite ignores this setting. The dashboard Infrastructure page exposes the field, and the environment variable is validated as a legal, non-reserved Postgres identifier at boot.
Changed
- OpenAPI/Swagger tag hygiene. Every controller tag is now declared in the API document (ten were used but undeclared), the three Integration Fabric controllers gained
@ApiTags, and the tag casing is uniform — so/api/docsgroups every endpoint under a described tag instead of leaving some ungrouped. - Graceful shutdown now drains on
SIGTERM/SIGINT(rolling deploys,docker stop, Ctrl+C), not just on the admin restart endpoint. On a termination signal the app flips readiness to503immediately so a load balancer/orchestrator stops routing, keeps serving in-flight requests for a bounded grace window, then tears down and exits deterministically.⚠️ Behavior change: adocker stop/ redeploy now takes up to the grace window (SHUTDOWN_DELAY_MS, default 3s in production, 0 in dev) plus teardown instead of tearing down instantly, and the process now exits0on a clean signal. In Docker setstop_grace_period≥SHUTDOWN_DELAY_MS+ your worst-case teardown (the bundled compose now sets45s); for Kubernetes setterminationGracePeriodSecondsaccordingly. A second signal during the drain forces an immediate exit. The whatsapp-web.js engine no longer lets Puppeteer install its own signal handlers (which previously killed Chromium at signal time /exit(130)before the drain could run). - The bundled Docker Compose stack pins its
docker-socket-proxyandminioimages to explicit tags (they were on:latest) for reproducible, non-drifting deploys, and a Dependabotdockerecosystem was added so base and stack images keep receiving update PRs. A Node>=22enginesfloor +.nvmrcwere declared, and the transitive install-time Scarf telemetry (viaswagger-ui-dist) is disabled.
Fixed
- The Integration Fabric now works on PostgreSQL.
conversation_mappingsandintegration_delivery_failuresdeclared@PrimaryGeneratedColumn('uuid')ids but their columns were created without a PostgresDEFAULT gen_random_uuid(), so on PostgreSQL every first insert failed with aNOT NULLviolation onid— breaking the plugin conversation-mapping upsert (e.g. the Chatwoot handover) and the ingress dead-letter write. SQLite was unaffected because its driver mints the uuid client-side, which is why it went unnoticed. A forward-only migration adds the default (no-op on SQLite). A new CI job now applies the full migration chain against a real PostgreSQL and asserts every generated-uuid primary key has a database default, so this dialect gap can't recur. - Indexed
webhooks.sessionId. The webhook dispatch path looks up a session's active webhooks bysessionIdon every emitted event, so on a busy session this was a full table scan of thewebhookstable per event (the foreign-key column carried no index). A cross-dialect index migration — plus the matching entity index — makes the lookup index-backed. - Boot now rejects a non-canonical boolean feature flag instead of silently disabling the feature.
QUEUE_ENABLED,MCP_ENABLED, andSERVE_DASHBOARDare read with an exact=== 'true'/!== 'false'comparison, so a typo (True,1,yes) or a stray trailing space/CR (a Windows-edited env file forwarded verbatim bydocker run --env-file) silently (dis)abled the feature with zero diagnostics. These are now validated at startup and boot fails fast naming the offending key.⚠️ Behavior change: a deployment currently booting with such a value (e.g.QUEUE_ENABLED=1) will now refuse to start until corrected totrue/false/unset — includingSERVE_DASHBOARD=0/no, which was silently serving the dashboard and will now correctly disable it once set tofalse. - A fatal uncaught exception is now written to the structured log (with its stack and origin) before the process exits, instead of only a raw stack on stderr that the log pipeline missed. This is observe-only: the crash-and-restart posture is unchanged (the container restart policy still fires and the process never continues on corrupted post-exception state).
POST /infra/import-datano longer swallows a genuine database error while clearing tables. The table-clearing step tolerated only a genuinely-absent table but previously used a blanket catch, so an I/O/lock error (or an aborted transaction) could let a restore commit a merged rather than replaced dataset on SQLite. Such errors now surface and roll the whole import back (a real fault returns a500carrying the actual cause); the intended tolerance for a missing table is preserved.- A session no longer schedules a reconnect while the process is shutting down — a disconnect during the drain window would otherwise launch a fresh Chromium racing the shutdown teardown. The session is left
DISCONNECTED(a later start / auto-restore re-initializes it cleanly). - Documentation & config accuracy.
.env.examplenow documentsPORT(the port the app binds to on bare metal) distinctly from the Compose-only host-publishedAPI_PORT, and adds theQUEUE_ENABLED/CACHE_ENABLEDtoggles.SECURITY.md's supported-versions table and the Java SDK install snippets are refreshed to the current releases. The unuseduuid/@types/uuiddependency was removed, and stale "not yet wired" comments on the plugin ingress-manifest validation (which the loader has called since it shipped) were corrected. - The bundled Docker Compose stack no longer kills Chromium mid-spawn under multi-session
whatsapp-web.jsworkloads (#636). The per-containerpids_limitshipped at512since the#243hardening pass — a fork-bomb guard chosen without accounting for Chromium's multi-process model.whatsapp-web.jsruns a full Chromium instance per session (browser + renderer + GPU + zygote + utilities), and WhatsApp Web is itself process-heavy, so ~4 concurrent sessions already approached 512 and the next session's Chromium was killed mid-spawn whenfork()returnedEAGAIN— surfacing in the API as aFailed to launch the browser process: Code: nulllaunch failure with no useful log (the dbus/crashpad noise in the log is non-fatal). The default is now2048(fits ~8–10 sessions with startup-spike headroom), exposed asOPENWA_PIDS_LIMITfor larger fleets. The limit is a cgrouppids.maxceiling, not an allocation — raising it is a no-op for light containers, so this is safe for thebaileysengine (single-process, no Chromium, a handful of PIDs regardless). The fork-bomb guard stays finite (-1/unlimited is explicitly discouraged). A new troubleshooting entry distinguishes the three causes ofCode: null(PID exhaustion vs OOM-kill vs the XDG/crashpad crash already fixed earlier), since the cause isn't visible in the log withoutdocker stats/dmesg.
v0.8.9
Changed
- Dashboard
<select>elements replaced with a custom dropdown component. The "All Status" filter (Sessions), "All Severities" filter (Logs), and language picker (Login) now use a reusableCustomSelectcomponent that matches the dashboard design system with proper dark/light theming, keyboard navigation (arrows, Home/End, type-ahead, Escape), and responsive behavior. Focus returns to the trigger on close, matching native<select>semantics. Thanks @haseeblodhi1899. - The "Install a plugin" modal is wider on desktop (480px → 680px) to give the plugin catalog list more room, while still collapsing to a full-width bottom sheet on small screens.
- Webhook delivery-failure records are pruned on a retention window.
webhook_delivery_failuresis an append-only log written on every terminally-failed delivery, so under a receiver outage it grew without bound. It is now pruned toWEBHOOK_FAILURE_RETENTION_DAYS(default 90; set<= 0to disable) once at startup and daily, mirroring the existing audit-log retention.
Fixed
- A malformed session id now returns
400instead of a500on PostgreSQL. The session routes validate the:idpath param as a UUID at the boundary, so a non-UUID id (a typo or path fuzzing) is rejected with a clean400rather than reaching theuuidprimary-key column and raising an uncaught cast error that surfaced as a generic500— a divergence that only appeared on PostgreSQL (SQLite treated the id as text and returned404). - Baileys API sends now emit
message.sent(parity with the whatsapp-web.js engine). The wwjs engine fires this for the account's own sends; the Baileys engine's own socket-sends echo back only as a skipped history-sync upsert, somessage.sentwebhooks / WebSocket events / themessage:senthook never fired for Baileys API sends. They now fire for text and every media/location/contact/poll/reply/forward send (reactions and deletes excluded). - Config & reliability hardening.
DATABASE_STATEMENT_TIMEOUT_MS,DATABASE_IDLE_TIMEOUT_MS, andDATABASE_CONNECTION_TIMEOUT_MSare validated at boot — a typo previously reached the config layer asNaNand broke the PostgreSQL pool. An unparseableBODY_SIZE_LIMITno longer silently disables the request body-size cap (it falls back to the 25 MB default). The channel-messages endpoint no longer forwardsNaNto the engine on a non-numeric?limit. The fire-and-forget session-row writes in the engine callbacks now handle a transient DB fault instead of surfacing an unhandled rejection, and a set of engine-adapter warning logs no longer mislabel their component name. - A terminally-failed or un-reinitializable session no longer strands its browser process or wedges at "already started". When an engine reports a terminal error, and when a reconnect attempt's re-initialization throws, the dead or half-built engine is now evicted from the session registry and its Chromium process is force-killed instead of being left in place — previously it kept holding a concurrency slot and caused a later start to be rejected as already running. Deleting a session likewise force-kills its browser (rather than a graceful close that could hang on a wedged Chromium and orphan the process).
- The dark theme now covers every dashboard surface. A number of components used hardcoded colors instead of the theme's CSS variables, so several surfaces stayed light in dark mode — most visibly the Infrastructure "Database Migrations" card, plus status/severity badges, toasts, danger-hover states, and toggle tracks across most pages. They now use the theme tokens (and translucent semantic fills) so they follow the active theme in both light and dark. A new
--infotoken themes the blue badges (permission, SQLite, info logs, qr-ready pill) that previously had no theme-aware color, and the root<html>background no longer stays white when the dark theme is selected on a light-OS device (visible on overscroll).
v0.8.8
Added
- Native WhatsApp polls via
POST /api/sessions/:sessionId/messages/send-poll: question, 2–12 options and an optionalallowMultipleAnswersflag (default single choice), implemented on both engines (whatsapp-web.jsPoll, Baileyspollcontent withselectableCount1/0). The message history stores the poll question as the body so the log stays readable. Polls are a first-classpollmessage type end to end — both engines map incoming poll messages to it, so the websocket/webhook events, persisted rows, and dashboard all reportpollconsistently. Thanks @alejo117.
Changed
- Corrected the Italian login-footer wording. Thanks @albanobattistella.
Fixed
GET /api/sessions/:sessionId/channels/:channelId/messagesalways returned an empty array on the whatsapp-web.js engine (#625). The adapter calledclient.getChannelById(), which does not exist in whatsapp-web.js 1.34.x, so every call threw and the error was swallowed into[]. Channel messages are now read from the subscribedChannelinstance (viagetChannels()), and an unknown/unsubscribed channel returns a404(ChannelNotFoundError) instead of a silent empty200— matchingGET /channels/:channelId. Thanks @Header9968.- A session whose
engine.initialize()fails no longer orphans its browser process. The crash-recovery path inSessionService.start()was tearing down the half-built engine with a gracefuldestroy(), but a failedinitialize()usually means the underlying browser/CDP connection is already broken (e.g. aTargetCloseError: Target closedmid-injection) —destroy()has nothing live to talk to, so it could only time out after 10s viateardownEngineSafely's race, leaving the Chromium process alive and orphaned. Every such crash left one more orphaned process behind, eventually starving the host of memory. It now usesforceDestroy()(the same SIGKILL-the-process recoveryPOST /:id/force-killuses), since a failed initialize is the same "possibly-unreachable engine" state that exists for. - Authenticated HTTP/HTTPS proxies now work on the whatsapp-web.js engine (#628). Credentials were passed inside
--proxy-server, which Chromium ignores, so a proxy with a username/password never authenticated (only IP-authorized proxies worked). The username/password are now handed to whatsapp-web.js'sproxyAuthentication(which drives Chromium'spage.authenticate) while--proxy-servergets a credential-less URL. SOCKS proxies still cannot be authenticated — Chromium does not support SOCKS proxy authentication at all — so a SOCKS proxy carrying credentials now logs a clear warning instead of failing with an opaque navigation timeout. Thanks @gudge25.
v0.8.7
Added
- Plugins can canonicalize a chat id via a new
ctx.engine.canonicalChatId(sessionId, chatId)capability, gated by theengine:readpermission like the other engine reads. It resolves a@lidprivacy id to its stable<phone>@c.usform when the mapping is known (best-effort; an unresolved id passes through), letting a plugin key a chat by one identity across WhatsApp's@lidmigration. This is the host-side prerequisite for an adapter to keep a contact's conversation from splitting when they migrate to@lid. (#615)
v0.8.6
Fixed
- The
engine.getChatHistoryplugin capability (added in 0.8.5) now reaches sandboxed plugins. It was wired only into the host-side context, not the plugin-worker bridge, so a sandboxed plugin'sctx.engine.getChatHistorywasundefinedand the call failed silently. It is now bridged through the worker capability + router like the other engine reads. Historical messages from the whatsapp-web.js engine also carry location coordinates and quoted-message references now, matching the live message path (previously a backfilled location rendered empty and replies lost their thread link). (#609)
v0.8.5
Added
- Plugins can read recent chat history via a new
ctx.engine.getChatHistory(sessionId, chatId, limit?, includeMedia?)capability, gated by theengine:readpermission and the plugin's active-session scope like the other engine reads. The limit is clamped host-side (max 100), and both message directions are returned. This is the host-side prerequisite for an adapter to backfill prior conversation context. (#609)