Skip to content
Closed
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
84 changes: 78 additions & 6 deletions advanced/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<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 on inbound stanzas.
</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 |

<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 @@ -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<Arc<Self::Api>>> {
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The "Interaction with Signal durability" claim that a claimed stanza involves "nothing decrypted, no session mutated, no prekey consumed" directly contradicts the sibling add_stanza_interceptor section in api/client.mdx (same PR stack), which states a claimed <message> "has already been decrypted or not by the time an interceptor sees it, exactly as the built-in handler would have found it". A plugin author reading both pages gets opposite guidance about whether claiming advances Signal state/prekeys, which undercuts the no-durability-contract rationale here. Reconcile the two: either the interceptor runs after decryption (keeping the client.mdx framing) and this section must not claim zero Signal work, or interception truly precedes all Signal work and client.mdx needs correcting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At advanced/plugins.mdx, line 216:

<comment>The "Interaction with Signal durability" claim that a claimed stanza involves "nothing decrypted, no session mutated, no prekey consumed" directly contradicts the sibling `add_stanza_interceptor` section in api/client.mdx (same PR stack), which states a claimed `<message>` "has already been decrypted or not by the time an interceptor sees it, exactly as the built-in handler would have found it". A plugin author reading both pages gets opposite guidance about whether claiming advances Signal state/prekeys, which undercuts the no-durability-contract rationale here. Reconcile the two: either the interceptor runs after decryption (keeping the client.mdx framing) and this section must not claim zero Signal work, or interception truly precedes all Signal work and client.mdx needs correcting.</comment>

<file context>
@@ -141,11 +142,80 @@ impl ClientPlugin for SearchPlugin {
+
+### 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 `<message>` stays undecrypted forever, and a claimed `<notification type="encrypt">` 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.
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same contradiction Greptile flagged on the api/client.mdx side — fixed there in b58f0e4, so this line's framing ("nothing decrypted, no session mutated, no prekey consumed") is now the reconciled, correct account: interception truly precedes all Signal work. Nothing to change here in advanced/plugins.mdx.


Generated by Claude Code


What it does mean is that the claim is final. The ack that follows tells the server not to redeliver, so a claimed `<message>` stays undecrypted forever, and a claimed `<notification type="encrypt">` 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

Expand Down Expand Up @@ -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.

Expand All @@ -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.
80 changes: 80 additions & 0 deletions api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Warning>

### add_stanza_interceptor

```rust
pub fn add_stanza_interceptor(
self: &Arc<Self>,
interceptor: Arc<dyn StanzaInterceptor>,
) -> 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.

<ParamField path="interceptor" type="Arc<dyn StanzaInterceptor>" required>
Implementation of the `StanzaInterceptor` trait (`fn intercept(&self, node: &OwnedNodeRef) -> Interception`). A `Fn(&OwnedNodeRef) -> Interception` closure also implements the trait.
</ParamField>

<ResponseField name="InterceptorHandle" type="InterceptorHandle">
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.
</ResponseField>

**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 `<receipt>` for a `<message>`, an `<iq type="result">`) is answered by nobody once claimed — the claimant owes that reply.

<Note>
A handful of connection-critical stanzas — `success`, `failure`, `stream:error`, `ack`, and a server-initiated `<iq>` 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.
</Note>

<Note>
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 `<message>` is one the client did no Signal work on at all — nothing decrypted, no session mutated, no prekey consumed. Claiming a `<message>` 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.
</Note>

### acquire_decrypted_payload_forwarding

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

Acquires a lease that keeps [`Event::DecryptedPayload`](/concepts/events#decryptedpayload) enabled. The event fires for every decrypted `<enc>` 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.

<ResponseField name="DecryptedPayloadLease" type="DecryptedPayloadLease">
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.
</ResponseField>

**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
Expand Down Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions concepts/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ pub enum Event {
// Raw stanza (opt-in)
RawNode(Arc<OwnedNodeRef>),

// Decrypted payload, before decoding (opt-in)
DecryptedPayload(DecryptedPayload),

// Passkey linking (SHORTCAKE_PASSKEY)
PairPasskeyRequest(PairPasskeyRequest),
PairPasskeyConfirmation(PairPasskeyConfirmation),
Expand Down Expand Up @@ -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.
</Note>

## Decrypted payload events

### `DecryptedPayload`

**Emitted:** Once per decrypted `<enc>` 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<MessageInfo>,
pub enc_index: usize,
pub enc_type: &'static str,
#[serde(skip)]
pub payload: Bytes,
}
```

**Fields:**
- `info` - The [`MessageInfo`](#messages) this `<enc>` belongs to (sender, chat, message ID, timestamp)
- `enc_index` - Which `<enc>` of the stanza produced this payload, counting from zero across the stanza's direct `<enc>` children first, then the ones under `<participants><to>` addressed to this device. A message fanned out to several devices carries several `<enc>` 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 `<enc>` 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
);
}
```

<Note>
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 `<enc>` 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.
</Note>

## Event handler patterns

### Bot builder pattern
Expand Down