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
21 changes: 21 additions & 0 deletions advanced/signal-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,10 @@ Location: `wacore/src/send.rs:675-820`, `src/send.rs`

WhatsApp's multi-device architecture uses two addressing schemes: phone number JIDs (PN, `@s.whatsapp.net`) and Linked Identity JIDs (LID, `@lid`). WhatsApp Web always resolves PN→LID before any session operation via `createSignalAddress()`. whatsapp-rust mirrors this behavior — when a LID mapping is discovered for a phone number, any Signal sessions stored under the PN address are automatically migrated to the corresponding LID address.

<Note>
The automatic migration described below is also exposed for manual invocation: [`Signal::migrate_sessions(from, to)`](/api/signal#migrate_sessions) runs the same move for a caller-chosen JID pair, and [`Signal::session_info(jid)`](/api/signal#session_info) inspects a session (migrating a legacy PN-addressed one first if needed) without mutating it further. See the [Signal API reference](/api/signal) for both.
</Note>

### Signal address resolution

`Client::resolve_encryption_jid()` mirrors WA Web's `SignalAddress.toString()` (`WAWeb/Signal/Address.js`). It upgrades the JID's `server` to its LID counterpart when a mapping is known, and otherwise returns the input unchanged:
Expand Down Expand Up @@ -428,6 +432,10 @@ Identity keys are migrated independently of sessions — they can outlive delete
The migration reads through the cache because the backend may contain stale session data when unflushed cache mutations exist. Reading directly from the backend could skip in-flight ratchet advances, causing the migrated session to decrypt with an outdated chain key.
</Warning>

<Note>
`add_lid_pn_mapping` also has a batch form, `Client::add_lid_pn_mappings(mappings, source)`, which durably records many LID↔PN pairs in one call and runs the same per-mapping migration as the single-entry path. It returns how many mappings were actually written, deduplicated against existing records.
</Note>

### On-the-fly migration during decryption

If a message arrives from a LID address and decryption fails with `SessionNotFound` or `InvalidPreKeyId`, the client attempts PN→LID migration as a fallback before requesting a retry:
Expand Down Expand Up @@ -996,6 +1004,11 @@ client.refresh_pre_keys().await?;

Internally, this acquires `prekey_upload_lock` to prevent races with the count-based and digest-repair upload paths, then calls `upload_pre_keys_with_retry(force: true)` which uses Fibonacci backoff (1s, 2s, 3s, 5s, 8s, ... capped at 610s).

Two related public methods build on the same `prekey_upload_lock`-guarded path:

- `Client::refresh_pre_keys_with_count(count)` — same force-upload as `refresh_pre_keys()`, but with a caller-chosen batch size instead of the configured [`wanted_pre_key_count`](#configuration).
- `Client::ensure_pre_keys()` — a non-forced check-and-top-up: uploads only if the server-side pool is below the low-water mark, rather than unconditionally replacing it.

Location: `src/prekeys.rs:263-266`

### Digest key validation
Expand Down Expand Up @@ -1048,6 +1061,10 @@ After connection, the client validates that the server's copy of the key bundle
Hash mismatches or missing local pre-keys are logged but do **not** trigger a re-upload. Only a 404 response (server has no record) triggers re-upload. This matches WhatsApp Web's behavior where `validateLocalKeyBundle` exceptions are caught without re-uploading — the normal [`RotateKeyJob`](#signed-pre-key-rotation-rotatekeyjob) eventually refreshes the signed pre-key.
</Warning>

<Note>
`Client::validate_digest_key()` is a public method — callers can trigger this validation pass on demand instead of only relying on the automatic post-connection check.
</Note>

Location: `src/prekeys.rs:218-344`, `wacore/src/iq/prekeys.rs:170-302`

### Signed pre-key rotation (RotateKeyJob)
Expand Down Expand Up @@ -1097,6 +1114,10 @@ Before this feature, `Device::load_signed_prekey` (`src/store/signal.rs`) return

**Retry, not NACK, once the id ages past retention:** A sender's `PreKeySignalMessage` can still name a signed pre-key id that has since aged past `SIGNED_PRE_KEY_RETENTION` (3 total: current + 2 rotated-out) — the backend fallback above has nothing left to return, and `InvalidSignedPreKeyId` is the correct, permanent answer. On the 1:1 decrypt path (`src/message/receive.rs`), this now routes to a retry receipt (`RetryReason::InvalidKeyId`) carrying the current bundle, mirroring the sibling `InvalidPreKeyId` arm — instead of falling through to the catch-all `UnhandledError` nack, which would drop the stanza from the offline queue and lose the 1:1 message permanently and silently.

<Note>
`Client::rotate_signed_pre_key()` is a public method — callers can force an out-of-cadence rotation directly instead of waiting for the weekly check. It shares `signed_pre_key_rotation_lock` with the automatic path (so a manual call can't race a background rotation) and propagates upload failures to the caller rather than swallowing them.
</Note>

Location: `src/features/rotate_key.rs`, `src/store/signal.rs`, `src/message/receive.rs`, `wacore/src/iq/prekeys.rs`, `wacore/src/store/commands.rs`

### Re-pair pre-key healing (v0.6)
Expand Down
6 changes: 3 additions & 3 deletions api/business.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ if let Some(profile) = client.get_business_profile(&jid).await? {
if let Some(configs) = &profile.business_hours.business_config {
for config in configs {
println!(
" {:?}: {:?} ({}–{})",
" {:?}: {:?} ({:?}–{:?})",
config.day_of_week,
config.mode,
config.open_time,
Expand Down Expand Up @@ -126,8 +126,8 @@ pub struct BusinessHours {
pub struct BusinessHoursConfig {
pub day_of_week: DayOfWeek,
pub mode: BusinessHourMode,
pub open_time: u32,
pub close_time: u32,
pub open_time: Option<u32>,
pub close_time: Option<u32>,
}
```

Expand Down
94 changes: 94 additions & 0 deletions api/community.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ if let Some(desc) = &result.metadata.description {
Since v0.6, `community().create()` returns the full `GroupMetadata` instead of just the JID. The library inlines the community description directly into the create stanza (matching WA Web), so the returned metadata already contains it — no separate `set_description` round-trip is needed. If you previously read `result.gid`, switch to `result.metadata.id` (`GroupMetadata` uses `id: Jid`).
</Note>

### get_participating

Fetch all parent/community groups the logged-in account currently participates in.

```rust
pub async fn get_participating(&self) -> Result<HashMap<Jid, GroupMetadata>, CommunityError>
```

**Returns:**
- `HashMap<Jid, GroupMetadata>` — Map of community JID to metadata

**Example:**
```rust
let communities = client.community().get_participating().await?;

for (jid, metadata) in communities {
println!("Community: {} ({})", metadata.subject, jid);
}
```

### deactivate

Deactivate (delete) a community. Subgroups are unlinked but not deleted.
Expand Down Expand Up @@ -103,6 +123,44 @@ for (jid, error_code) in &result.failed_groups {
}
```

### create_subgroup

Create a new group that is already linked as a subgroup of a community, in one call.

```rust
pub async fn create_subgroup(
&self,
name: &str,
participants: &[Jid],
parent_jid: &Jid,
) -> Result<CreateCommunityResult, CommunityError>
```

**Parameters:**
- `name` — Name of the new subgroup
- `participants` — Initial participant JIDs to add to the subgroup
- `parent_jid` — JID of the parent community to link the new subgroup under

**Returns:**
- `CreateCommunityResult` — Contains the full `metadata: GroupMetadata` for the created subgroup

**Example:**
```rust
let participants = vec![
"5511999999999@s.whatsapp.net".parse()?,
];

let result = client.community()
.create_subgroup("My Subgroup", &participants, &community_jid)
.await?;

println!("Created subgroup: {} ({})", result.metadata.subject, result.metadata.id);
```

<Note>
Equivalent to creating a group and then calling [`link_subgroups`](#link_subgroups), but done in a single round-trip.
</Note>

### unlink_subgroups

Unlink subgroups from a community.
Expand Down Expand Up @@ -274,6 +332,38 @@ for p in &participants {
}
```

### remove_participants

Remove participants from a community.

```rust
pub async fn remove_participants(
&self,
community_jid: &Jid,
participants: &[Jid],
) -> Result<Vec<ParticipantChangeResponse>, CommunityError>
```

**Parameters:**
- `community_jid` — JID of the community
- `participants` — Array of participant JIDs to remove

**Returns:**
- `Vec<ParticipantChangeResponse>` — Result for each participant (see [`ParticipantChangeResponse`](/api/groups#participantchangeresponse))

**Example:**
```rust
let to_remove = vec!["15551234567@s.whatsapp.net".parse()?];

let results = client.community()
.remove_participants(&community_jid, &to_remove)
.await?;

for result in results {
println!("{}: status {:?}", result.jid, result.status);
}
```

## Types

### CreateCommunityOptions
Expand Down Expand Up @@ -336,6 +426,8 @@ pub struct CommunitySubgroup {
pub participant_count: Option<u32>,
pub is_default_sub_group: bool,
pub is_general_chat: bool,
pub creation: Option<u64>,
pub owner: Option<Jid>,
}
```

Expand All @@ -347,6 +439,8 @@ pub struct CommunitySubgroup {
- `participant_count` — Number of participants (if available)
- `is_default_sub_group` — Whether this is the default announcement subgroup
- `is_general_chat` — Whether this is the general chat subgroup
- `creation` — Subgroup creation timestamp (Unix seconds), if available
- `owner` — JID of the subgroup owner, if available

### LinkSubgroupsResult

Expand Down
4 changes: 4 additions & 0 deletions api/contacts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ if let Some(pic) = client.contacts().get_profile_picture(&group_jid, true).await
}
```

<Note>
To override the default request timeout for a single fetch, use `get_profile_picture_with_timeout(jid, preview, timeout)`, which takes an extra `timeout: Option<Duration>` argument. Internally the request is built via `ProfilePictureSpec`'s `with_timeout(...)` builder method; pass `None` to fall back to the default timeout.
</Note>

### get_user_info

Get user information by JID.
Expand Down
Loading