diff --git a/advanced/plugins.mdx b/advanced/plugins.mdx
index 030b4c70..511f8ab1 100644
--- a/advanced/plugins.mdx
+++ b/advanced/plugins.mdx
@@ -5,12 +5,12 @@ description: Build type-safe, capability-scoped extensions for whatsapp-rust beh
## Overview
-whatsapp-rust supports **native plugins**: build-time, type-safe extensions that get scoped access to core events, tasks, messaging, and IQ execution through a capability model, without touching the client's internals directly.
+whatsapp-rust supports **native plugins**: build-time, type-safe extensions that get scoped access to core events, tasks, messaging, IQ execution, and stanza interception through a capability model, without touching the client's internals directly.
This is a low-level extension seam. If you're writing an application that just sends and receives messages, use [`Bot`](/api/bot) directly — you don't need this page. Reach for native plugins when you're building a **reusable component** (metrics, a search index, a moderation layer) that should install once, observe events non-blockingly, own its own background tasks across reconnects, and expose a typed API to the rest of your application.
-The plugin host is intentionally native-only today: it is trusted in-process Rust code, not a sandboxed or dynamically-loaded extension model. There is no dynamic (`.so`/`.wasm`) loading, no ingress interception of inbound stanzas, and no pre-ack decision hook.
+The plugin host is intentionally native-only today: it is trusted in-process Rust code, not a sandboxed or dynamically-loaded extension model. There is no dynamic (`.so`/`.wasm`) loading and no pre-ack decision hook on inbound stanzas.
## Enabling the feature
@@ -107,6 +107,7 @@ A plugin's manifest requests capabilities, and `PluginContext` only exposes the
| `PluginCapability::Messaging` | `PluginMessaging` | High-level message sends |
| `PluginCapability::Iq` | `PluginIq` | Typed `IqSpec` execution |
| `PluginCapability::PluginEvents` | `PluginEvents` | Publishing custom events, scoped to the plugin's own namespace |
+| `PluginCapability::StanzaInterception` | `PluginStanzaInterception` | Claiming a decoded stanza before the built-in pipeline |
This is API shaping, not a sandbox: a native plugin is trusted in-process Rust code and can use any dependency available to your build. It exists so a plugin's `install` signature documents what it actually touches, and so `PluginContext` never hands out the raw backend or Signal stores.
@@ -141,11 +142,80 @@ impl ClientPlugin for SearchPlugin {
}
```
-The other capabilities follow the same pattern: `context.tasks()`, `context.messaging()`, `context.iq()`, and `context.plugin_events()` each return `Option<&Plugin*>`, populated only when the matching `PluginCapability` was requested in `manifest()`.
+The other capabilities follow the same pattern: `context.tasks()`, `context.messaging()`, `context.iq()`, `context.plugin_events()`, and `context.stanza_interception()` each return `Option<&Plugin*>`, populated only when the matching `PluginCapability` was requested in `manifest()`.
Capability handles hold a weak reference to the client internally and reject calls once the client has shut down, so a plugin API that outlives the client (for example, one your application keeps an `Arc` to) fails safely instead of resurrecting it.
-`PluginCoreEvents::subscribe` returns an RAII token for the registration. Dropping it, or explicitly unsubscribing, removes the handler immediately.
+`PluginCoreEvents::subscribe` returns an RAII token for the registration. Dropping it, or explicitly unsubscribing, removes the handler immediately. `PluginStanzaInterception::register` (below) returns the same shape of token.
+
+## Stanza interception
+
+The `CoreEvents` capability above lets a plugin *watch* the stream. `StanzaInterception` lets it *act*: a plugin that models a stanza this client does not can claim it, instead of watching the built-in pipeline nack it. This is the plugin-facing wrapper over the client's lower-level [`add_stanza_interceptor`](/api/client#add_stanza_interceptor) — see that page and `client::interceptor` for the full contract of what may be claimed and what the server is still owed afterward.
+
+Request the capability and register an implementation of `StanzaInterceptor`:
+
+```rust
+use std::sync::Arc;
+
+use wacore_binary::node::OwnedNodeRef;
+use whatsapp_rust::{
+ Interception, PluginCapability, PluginInterceptorRegistration, StanzaInterceptor,
+};
+
+struct VendorInterceptor;
+
+impl StanzaInterceptor for VendorInterceptor {
+ fn intercept(&self, node: &OwnedNodeRef) -> Interception {
+ if node.tag() == "vendor:thing" {
+ // ... act on it ...
+ Interception::Handled
+ } else {
+ Interception::Pass
+ }
+ }
+}
+
+struct VendorApi {
+ // Keeps the interceptor registered for as long as the API handle lives.
+ _registration: PluginInterceptorRegistration,
+}
+
+struct VendorPlugin;
+
+impl ClientPlugin for VendorPlugin {
+ type Api = VendorApi;
+
+ fn manifest(&self) -> PluginManifest {
+ PluginManifest::new("example.vendor", "0.1.0")
+ .with_capability(PluginCapability::StanzaInterception)
+ }
+
+ fn install(&self, context: PluginContext) -> PluginFuture<'_, Result>> {
+ Box::pin(async move {
+ let interception = context
+ .stanza_interception()
+ .expect("StanzaInterception capability was requested in the manifest above");
+ let registration = interception.register(Arc::new(VendorInterceptor))?;
+ Ok(Arc::new(VendorApi { _registration: registration }))
+ })
+ }
+}
+```
+
+`PluginStanzaInterception::register` returns a `PluginInterceptorRegistration` — the same shape of token `PluginCoreEvents::subscribe` returns: dropping it unregisters immediately, `unregister()` is the explicit form, and the host indexes it weakly so terminal shutdown invalidates a token a plugin API retained without extending the life of one the plugin already released.
+
+The host adds two things a directly registered interceptor doesn't get:
+
+- **Panic isolation.** A directly registered interceptor is trusted not to panic — the read loop calls it inline, so an unwind takes the connection with it. A plugin isn't trusted with that: a panicking interceptor is counted, logged, and read as `Interception::Pass`, which leaves the stanza with the client, the same outcome as the plugin not being there.
+- **Terminal invalidation.** Registrations are indexed weakly and closed on shutdown, so a client that's shutting down is never still asking a plugin what to do with its stanzas.
+
+`Client::plugin_stats()` reports `stanza_interceptors` (active registrations) and `stanza_interception_panics` per plugin; a non-zero panic count degrades the plugin's `PluginHealth`.
+
+### Interaction with Signal durability
+
+Interception runs *before* the built-in pipeline, so a claimed stanza is one the client did no Signal work on at all: nothing decrypted, no session mutated, no prekey consumed. There's no half-advanced state to reconcile, which is what makes claiming safe to reason about and needs no dedicated durability contract.
+
+What it does mean is that the claim is final. The ack that follows tells the server not to redeliver, so a claimed `` stays undecrypted forever, and a claimed `` is a prekey top-up that never happens. A plugin claiming stanzas that carry Signal state takes over that responsibility whole — see [Signal Protocol](/advanced/signal-protocol) for what that involves. Match narrowly.
## Lifecycle and task scopes
@@ -180,7 +250,7 @@ Each delivered envelope carries six fields: the plugin ID, the topic, a schema v
## Diagnostics
-`Client::plugin_stats()` returns a `PluginStats` snapshot per installed plugin: lifecycle state (`PluginState`), sticky health (`PluginHealth`), callback failures, spawned-task panics, drain timeouts, active task scopes, and subscription/publisher counters. `PluginEventRouter::stats()` and `PluginEvents::stats()` report queue depth and backpressure totals for custom events. `Client::memory_report()` folds in plugin resource accounting.
+`Client::plugin_stats()` returns a `PluginStats` snapshot per installed plugin: lifecycle state (`PluginState`), sticky health (`PluginHealth`), callback failures, spawned-task panics, drain timeouts, active task scopes, subscription/publisher counters, and — for a plugin holding the `StanzaInterception` capability — active interceptor registrations and isolated interceptor panics. `PluginEventRouter::stats()` and `PluginEvents::stats()` report queue depth and backpressure totals for custom events. `Client::memory_report()` folds in plugin resource accounting.
These snapshots are on-demand and approximate under concurrency, and — matching the rest of the client's [observability](/advanced/observability) surface — never include JIDs, phone numbers, or message bodies.
@@ -190,9 +260,11 @@ The workspace ships `plugins/metrics` as a public-API conformance example: a met
## What's not supported yet
-The current host deliberately does not support dynamic plugin loading, ingress interception or pre-ack decisions on inbound stanzas, a foreign (non-Rust) wire protocol, or process isolation/sandboxing. These are real design commitments, not just missing features, and would only be added behind a concrete consumer need.
+The current host deliberately does not support dynamic plugin loading, pre-ack decisions on inbound stanzas, a foreign (non-Rust) wire protocol, or process isolation/sandboxing. These are real design commitments, not just missing features, and would only be added behind a concrete consumer need.
## See also
+- [Client — `add_stanza_interceptor`](/api/client#add_stanza_interceptor) — the lower-level, non-plugin seam `stanza.intercept` wraps.
+- [Events — `DecryptedPayload`](/concepts/events#decryptedpayload) — a related library extension for recovering a plaintext the client couldn't decode, gated the same way.
- [Observability](/advanced/observability) — the `tracing` instrumentation that plugin diagnostics complement.
- [Custom backends](/guides/custom-backends) — the other `ClientBuilder` seam, for swapping storage/transport/runtime instead of adding behavior.
diff --git a/api/client.mdx b/api/client.mdx
index a630c5b9..7c1c5482 100644
--- a/api/client.mdx
+++ b/api/client.mdx
@@ -1846,6 +1846,85 @@ bot.on_event(|event, _client| async move {
Only enable this when you need raw protocol access. Every decoded stanza triggers the event, which adds overhead to the message processing pipeline.
+### add_stanza_interceptor
+
+```rust
+pub fn add_stanza_interceptor(
+ self: &Arc,
+ interceptor: Arc,
+) -> InterceptorHandle
+```
+
+Registers an interceptor that sees each decoded stanza before the built-in pipeline, and may claim it. Interceptors run in registration order; the first one to return `Interception::Handled` wins, and everything after it — including the built-in pipeline — is skipped.
+
+
+ Implementation of the `StanzaInterceptor` trait (`fn intercept(&self, node: &OwnedNodeRef) -> Interception`). A `Fn(&OwnedNodeRef) -> Interception` closure also implements the trait.
+
+
+
+ RAII token. Dropping it unregisters the interceptor immediately. It holds only a weak client reference, so a forgotten handle can't keep the client alive.
+
+
+**Example:**
+```rust
+use std::sync::Arc;
+use whatsapp_rust::client::interceptor::{Interception, StanzaInterceptor};
+use wacore_binary::node::OwnedNodeRef;
+
+struct Vendor;
+
+impl StanzaInterceptor for Vendor {
+ fn intercept(&self, node: &OwnedNodeRef) -> Interception {
+ if node.tag() == "vendor:thing" {
+ // ... act on it ...
+ Interception::Handled
+ } else {
+ Interception::Pass
+ }
+ }
+}
+
+let handle = client.add_stanza_interceptor(Arc::new(Vendor));
+// Dropping `handle` removes it.
+```
+
+`StanzaInterceptor::intercept` runs on the read loop and must not panic — an unwind there takes the connection down with it, the same contract `EventHandler` has. (A plugin registering through the plugin host's `stanza.intercept` capability gets panic isolation instead; see [Native plugins — Stanza interception](/advanced/plugins#stanza-interception).)
+
+A claim skips the built-in pipeline, but never the transport acknowledgement the server is owed: where the client would have acked the stanza it still does, and where it would have nacked a tag it doesn't model, the claim turns that into an ack instead, since something did handle it. A tag the client models but answers a different way (a delivery `` for a ``, an ``) is answered by nobody once claimed — the claimant owes that reply.
+
+
+A handful of connection-critical stanzas — `success`, `failure`, `stream:error`, `ack`, and a server-initiated `` ping — are never offered to an interceptor at all. Claiming one would leave the client authenticated-but-unaware, stuck mid-reconnect, waiting forever on a send that already completed, or disconnected for an unanswered ping. Offline-sync tracking and response-waiter resolution also run unconditionally, whether or not a stanza is claimed.
+
+
+
+This is a library extension with no WhatsApp Web equivalent, for a stanza this build does not model that your application can act on instead of watching it get nacked. It is not a filter for outbound stanzas, and not a decryption hook: interception runs *before* the built-in pipeline, so a claimed `` is one the client did no Signal work on at all — nothing decrypted, no session mutated, no prekey consumed. Claiming a `` takes over responsibility for whatever Signal state it carries; see [Native plugins — Interaction with Signal durability](/advanced/plugins#interaction-with-signal-durability) for what that means.
+
+
+### acquire_decrypted_payload_forwarding
+
+```rust
+pub fn acquire_decrypted_payload_forwarding(self: &Arc) -> DecryptedPayloadLease
+```
+
+Acquires a lease that keeps [`Event::DecryptedPayload`](/concepts/events#decryptedpayload) enabled. The event fires for every decrypted `` payload, right after unpadding and before this build tries to decode it into a `wa::Message` — including when that decode fails, which otherwise loses the bytes for good: decrypting already advanced the Signal ratchet, so the same ciphertext will never decrypt again.
+
+
+ RAII lease. `Event::DecryptedPayload` stays enabled until every acquired lease is dropped. While none is held, the path costs one relaxed atomic load and nothing is cloned; under a lease, forwarding the payload is a refcount bump on the same `bytes::Bytes` the decoder receives, not a copy.
+
+
+**Example:**
+```rust
+let _lease = client.acquire_decrypted_payload_forwarding();
+
+// In your event handler:
+if let Event::DecryptedPayload(payload) = &*event {
+ println!(
+ "{} bytes from a {} payload (enc #{})",
+ payload.payload.len(), payload.enc_type, payload.enc_index
+ );
+}
+```
+
---
## Call management
@@ -2104,6 +2183,7 @@ Entry counts plus estimated retained heap bytes for the client's internal collec
| `history_sync_payload_bytes_peak` | `u64` | Lifetime high-water mark of logical compressed-payload bytes |
| `chatstate_handlers` | `usize` | Registered chat state handlers |
| `custom_enc_handlers` | `usize` | Registered custom encryption handlers |
+| `stanza_interceptors` | `usize` | Interceptors registered via [`add_stanza_interceptor`](#add_stanza_interceptor) ([#1239](https://github.com/oxidezap/whatsapp-rust/pull/1239)) |
`CollectionStats` carries both `entries: u64` and `bytes: u64`. `MemoryReport::total_estimated_bytes(&self) -> u64` sums `.bytes` across every byte-carrying field. `MemoryReport` implements `Display` for a pretty-printed, human-readable breakdown. This output includes an `--- In-flight history sync ---` section with the two peak fields above.
diff --git a/concepts/events.mdx b/concepts/events.mdx
index f0fba400..fbe2b17a 100644
--- a/concepts/events.mdx
+++ b/concepts/events.mdx
@@ -208,6 +208,9 @@ pub enum Event {
// Raw stanza (opt-in)
RawNode(Arc),
+ // Decrypted payload, before decoding (opt-in)
+ DecryptedPayload(DecryptedPayload),
+
// Passkey linking (SHORTCAKE_PASSKEY)
PairPasskeyRequest(PairPasskeyRequest),
PairPasskeyConfirmation(PairPasskeyConfirmation),
@@ -2645,6 +2648,49 @@ Event::RawNode(node) => {
`RawNode` is skipped during serialization (`#[serde(skip)]`). Enable it only when debugging or building protocol-level tooling, as it dispatches for every incoming stanza.
+## Decrypted payload events
+
+### `DecryptedPayload`
+
+**Emitted:** Once per decrypted `` payload, right after unpadding and before the client tries to decode it into a `wa::Message` — whether or not that decode goes on to succeed. To receive it, hold a lease from `client.acquire_decrypted_payload_forwarding()` and include `EventKind::DecryptedPayload` in your handler's `interest()`. While no lease is held, nothing is emitted and nothing is cloned.
+
+```rust
+#[derive(Debug, Clone, Serialize, bon::Builder)]
+#[non_exhaustive]
+pub struct DecryptedPayload {
+ pub info: Arc,
+ pub enc_index: usize,
+ pub enc_type: &'static str,
+ #[serde(skip)]
+ pub payload: Bytes,
+}
+```
+
+**Fields:**
+- `info` - The [`MessageInfo`](#messages) this `` belongs to (sender, chat, message ID, timestamp)
+- `enc_index` - Which `` of the stanza produced this payload, counting from zero across the stanza's direct `` children first, then the ones under `` addressed to this device. A message fanned out to several devices carries several `` nodes; this is not a child index, so mapping it back to a node means walking the same two groups in the same order.
+- `enc_type` - The `type` attribute the `` carried: `"msg"`, `"pkmsg"`, `"skmsg"`, …
+- `payload` - The plaintext, unpadded, exactly as decoding receives it. A `Bytes`, so a consumer holding a lease gets a refcount bump rather than a copy. Skipped during serialization (`#[serde(skip)]`) — there's no text encoding for raw bytes this type should be choosing on a consumer's behalf.
+
+This is a library extension with no WhatsApp Web equivalent. The bytes it carries cost a real decryption and are the only copy that will ever exist: decrypting already advanced the Signal ratchet, so a payload that fails to decode — a field this build predates, a message type it doesn't model — is otherwise gone for good, with only a warning log to show for it. Reasons to want it: recording traffic for faithful replay (re-encoding a decoded `Message` doesn't reproduce the original bytes), decoding with a newer protobuf than this build carries, or inspecting a payload that failed to decode instead of only reading that it did.
+
+**Example:**
+```rust
+let _lease = client.acquire_decrypted_payload_forwarding();
+
+// In your event handler:
+Event::DecryptedPayload(payload) => {
+ println!(
+ "enc[{}] type={} {} bytes for message {}",
+ payload.enc_index, payload.enc_type, payload.payload.len(), payload.info.id
+ );
+}
+```
+
+
+Gated by `client.acquire_decrypted_payload_forwarding()`: while no consumer holds a lease, nothing is emitted and the path costs one relaxed atomic load. Every `` payload is forwarded here regardless of decode outcome, so treat it like `RawNode` — enable it for tooling, diagnostics, or replay, not as a default-on handler.
+
+
## Event handler patterns
### Bot builder pattern