Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions advanced/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ whatsapp-rust supports **native plugins**: build-time, type-safe extensions that
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.

<Note>
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. Ingress interception — claiming a stanza before the built-in pipeline — is supported via the `PluginCapability::StanzaInterception` capability below.
</Note>

## Enabling the feature
Expand Down Expand Up @@ -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 sees it |

<Note>
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.
Expand Down Expand Up @@ -145,7 +146,7 @@ The other capabilities follow the same pattern: `context.tasks()`, `context.mess

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. Both are indexed weakly in the host, so terminal shutdown can invalidate a token a plugin API retained without extending the lifetime of one the plugin already released.

## Lifecycle and task scopes

Expand Down Expand Up @@ -178,9 +179,53 @@ Each delivered envelope carries six fields: the plugin ID, the topic, a schema v

`PluginEventSubscription` is an RAII endpoint — dropping it removes all of that subscriber's selectors from the router at once.

## Stanza interception

`PluginCapability::CoreEvents` lets a plugin *watch* the stream. `PluginCapability::StanzaInterception` lets it *act*: a plugin that models a stanza this client does not can claim it before the built-in pipeline sees it, instead of watching it get nacked. The underlying seam — `Client::add_stanza_interceptor`, the `StanzaInterceptor` trait, what is never offered, and the ack a claim still owes the server — is documented on [`add_stanza_interceptor`](/api/client#add_stanza_interceptor); this section covers what the plugin host adds on top of it.

```rust
use std::sync::Arc;

use whatsapp_rust::{PluginCapability, PluginStanzaInterception};
use whatsapp_rust::client::interceptor::{Interception, StanzaInterceptor};

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<Arc<Self::Api>>> {
Box::pin(async move {
let interception = context
.stanza_interception()
.cloned()
.expect("StanzaInterception capability was requested in the manifest above");
let registration = interception.register(Arc::new(VendorInterceptor))?;
Ok(Arc::new(VendorApi { registration }))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}
}
```

`PluginStanzaInterception::register` returns a `PluginInterceptorRegistration` — the same ownership-token shape `PluginCoreEvents::subscribe` returns. Dropping it unregisters; `unregister()` is the explicit form; the host indexes it weakly so terminal shutdown invalidates a token a plugin API retained.

The host adds two things a directly registered interceptor doesn't get:

- **Panic isolation.** `StanzaInterceptor` asks implementations not to panic, and a directly registered interceptor is trusted to honor that — the read loop calls it inline, so an unwind there takes the connection with it. A plugin is not trusted with that: one faulty plugin must not kill the connection. A panicking interceptor is counted (`stanza_interception_panics` in `PluginStats`), logged, and treated as `Interception::Pass` — the outcome of the plugin not being there — which also degrades the plugin's `PluginHealth`.
- **Terminal invalidation.** Like every other plugin registration, a stanza interceptor is closed on shutdown, so a client that is shutting down is not still asking a plugin what to do with its stanzas.

<Warning>
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 is no half-advanced state to reconcile — the property that makes claiming safe to reason about without a new durability contract. But it also means the claim is final: the ack that follows tells the server not to redeliver, so a claimed `<message>` stays undecrypted forever and a claimed prekey-bearing `<notification>` never tops up prekeys. A plugin claiming stanzas that carry Signal state takes over that responsibility whole — match narrowly, and see [Signal durability](/advanced/signal-protocol) for what the responsibility involves.
</Warning>

`Client::memory_report()`'s [`plugin_stanza_interceptors`](/api/client#memory_report) field folds in every plugin's active interceptor count.

## 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 — since stanza interception — `stanza_interceptors` and `stanza_interception_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.

Expand All @@ -190,7 +235,7 @@ 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. Ingress interception (claiming a stanza, above) shipped once its interaction with Signal durability had a design — see the warning in [Stanza interception](#stanza-interception).

## See also

Expand Down
119 changes: 119 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1846,6 +1846,123 @@ 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.
</Warning>

### add_stanza_interceptor

```rust
pub fn add_stanza_interceptor(
self: &Arc<Self>,
interceptor: Arc<dyn StanzaInterceptor>,
) -> InterceptorHandle
```

Register an interceptor that sees each decoded stanza before the built-in pipeline, and may take it. This is the seam for acting on a stanza this client does not model — `StanzaRouter::register` panics on a duplicate tag, so even an existing tag can't be handled differently any other way — instead of watching it get nacked.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<ParamField path="interceptor" type="Arc<dyn StanzaInterceptor>" required>
The interceptor to register. A plain closure of type `Fn(&OwnedNodeRef) -> Interception` implements `StanzaInterceptor` too, so `client.add_stanza_interceptor(Arc::new(|node: &OwnedNodeRef| { .. }))` works without a named type.
</ParamField>

<ResponseField name="InterceptorHandle" type="InterceptorHandle">
RAII token for the registration. Dropping it removes the interceptor. The handle holds only a weak client reference, so a forgotten handle cannot keep the client alive, and dropping one after its client is already gone is a no-op rather than a panic.
</ResponseField>

```rust
use wacore::sync_marker::MaybeSendSync;

pub trait StanzaInterceptor: MaybeSendSync + 'static {
fn intercept(&self, node: &OwnedNodeRef) -> Interception;
}

pub enum Interception {
/// Leave the stanza to the client. The default.
Pass,
/// The interceptor took the stanza; the built-in pipeline is skipped.
Handled,
}
```

`MaybeSendSync` is `Send + Sync` on native targets and carries no bounds on `wasm32`, matching the convention used by `EventHandler`, `Transport`, and `HttpClient`.

Interceptors run in registration order; the first one to return `Interception::Handled` wins and the rest — including the built-in pipeline — are skipped. Registration order is therefore priority order: an earlier registration can shadow a later one.

**What an interceptor never sees:** `success`, `failure`, `stream:error`, and `ack` settle connection state (authentication, shutdown/reconnection, and the waiters a send blocks on), and a server-initiated `<iq>` ping is withheld for the same reason — a claimed ping is a pong never sent, and the server drops the connection over it. Offline-sync tracking and response-waiter resolution run before dispatch and keep running whether or not a stanza is claimed. Every other stanza, including `<iq>` traffic the client already answers on its own, is offered.

**What claiming owes the server:** a claim doesn't change what the server is owed. Where the client would have acked a stanza, it still acks; where it would have nacked a tag it doesn't model, the claim turns that into an ack instead, since something did handle it — answering nothing would leave the stanza in the offline queue. A tag the client models but answers some other way (a delivery `<receipt>` for a direct `<message>`, an `<iq type="result">`) gets nothing from the claimed-stanza path — the claimant owes that reply itself.

<Note>
Cost while unused: one relaxed atomic load on the read loop, checked before any lock. Registering is what turns the check into a walk over the registered interceptors.
</Note>

**Example:**
```rust
use whatsapp_rust::client::interceptor::{Interception, StanzaInterceptor};
use wacore_binary::node::OwnedNodeRef;
use std::sync::Arc;

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.
```

<Warning>
Interception runs before the built-in pipeline — including Signal decryption — so a claimed `<message>` was never decrypted and a claimed prekey-bearing `<notification>` never tops up prekeys. The ack that follows tells the server not to redeliver, so that work never happens again. Match narrowly.
</Warning>

See the `whatsapp_rust::client::interceptor` module for the full contract, and [Native plugins](/advanced/plugins#stanza-interception) for the capability-gated version available to plugins.

### acquire_decrypted_payload_forwarding

```rust
pub fn acquire_decrypted_payload_forwarding(self: &Arc<Self>) -> DecryptedPayloadLease
```

Acquire a lease that keeps [`Event::DecryptedPayload`](/concepts/events#decryptedpayload) enabled for one consumer. The lease is necessary but not sufficient: a handler that has narrowed its `interest()` away from the default `EventInterest::ALL` also needs `EventKind::DecryptedPayload` added back in, or it won't see the event even while a lease is held. The event carries a message's plaintext *before* it is decoded into a `wa::Message` — the only way to recover a payload that decrypts successfully but fails to decode (a field a build predates, a message type it doesn't model). Nothing can ask for those bytes again: opening them already consumed state that won't recur — the Signal ratchet advances, or (for a bot's `message_secret` payload) the single-use secret is spent — so the same ciphertext will never open a second time.

<ResponseField name="DecryptedPayloadLease" type="DecryptedPayloadLease">
RAII lease. `Event::DecryptedPayload` stays enabled until every acquired lease is dropped — hold it for as long as you want the event forwarded. The lease holds only a weak client reference, so it cannot keep the client alive.
</ResponseField>

<Note>
While no lease is held, nothing is emitted and nothing is cloned — the path costs one relaxed atomic load. Under a lease, the forwarded payload is the same `bytes::Bytes` the decoder receives, so forwarding it is a refcount bump rather than a copy.
</Note>

**Example:**
```rust
use wacore::types::events::{ChannelEventHandler, Event};

let _lease = client.acquire_decrypted_payload_forwarding();

let (handler, event_rx) = ChannelEventHandler::new();
client.register_handler(handler);

while let Ok(event) = event_rx.recv().await {
if let Event::DecryptedPayload(payload) = &*event {
// `payload.payload` is the unpadded plaintext, exactly as decoding
// will receive it — record it, or inspect it when decoding later
// fails for this message.
println!(
"enc #{} ({}) for {}: {} bytes",
payload.enc_index,
payload.enc_type,
payload.info.id,
payload.payload.len(),
);
}
}
// Drop `_lease` to stop forwarding.
```

---

## Call management
Expand Down Expand Up @@ -2104,6 +2221,8 @@ 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` | Registered stanza interceptors ([`add_stanza_interceptor`](#add_stanza_interceptor)). A handle that outlives its interest leaves one registered, and a leak here costs a walk on every stanza — which this count is what makes visible ([#1239](https://github.com/oxidezap/whatsapp-rust/pull/1239)) |
| `plugin_stanza_interceptors` | `u64` | Behind the `plugins` feature: sum of every installed plugin's active [stanza interceptors](/advanced/plugins#stanza-interception) ([#1241](https://github.com/oxidezap/whatsapp-rust/pull/1241)) |

`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.

Expand Down
Loading