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
22 changes: 22 additions & 0 deletions src/appstate_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,28 @@ mod tests {
async fn consume_forget_marks(&self, _: &str) -> StoreResult<Vec<String>> {
Ok(vec![])
}
async fn get_tc_token(
&self,
_: &str,
) -> StoreResult<Option<wacore::store::traits::TcTokenEntry>> {
Ok(None)
}
async fn put_tc_token(
&self,
_: &str,
_: &wacore::store::traits::TcTokenEntry,
) -> StoreResult<()> {
Ok(())
}
async fn delete_tc_token(&self, _: &str) -> StoreResult<()> {
Ok(())
}
async fn get_all_tc_token_jids(&self) -> StoreResult<Vec<String>> {
Ok(vec![])
}
async fn delete_expired_tc_tokens(&self, _: i64) -> StoreResult<u32> {
Ok(0)
}
}

// Implement DeviceStore - Device persistence
Expand Down
5 changes: 5 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,11 @@ impl Client {
if let Err(e) = r_digest {
warn!("Background init: Failed to send digest: {e:?}");
}

// Prune expired tcTokens on connect (matches WhatsApp Web's PrivacyTokenJob)
if let Err(e) = bg_client.tc_token().prune_expired().await {
warn!("Background init: Failed to prune expired tc_tokens: {e:?}");
}
});

client_clone
Expand Down
12 changes: 10 additions & 2 deletions src/features/contacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use log::debug;
use std::collections::HashMap;
use wacore::iq::contacts::{ProfilePictureSpec, ProfilePictureType};
use wacore::iq::usync::{ContactInfoSpec, IsOnWhatsAppSpec, UserInfoSpec};
use wacore_binary::jid::Jid;
use wacore_binary::jid::{Jid, JidExt};

// Re-export types from wacore
pub use wacore::iq::contacts::ProfilePicture;
Expand Down Expand Up @@ -68,7 +68,15 @@ impl<'a> Contacts<'a> {
} else {
ProfilePictureType::Full
};
let spec = ProfilePictureSpec::new(jid, picture_type);
let mut spec = ProfilePictureSpec::new(jid, picture_type);

// Include tctoken for user JIDs (skip groups, newsletters)
if !jid.is_group()
&& !jid.is_newsletter()
&& let Some(token) = self.client.lookup_tc_token_for_jid(jid).await
{
spec = spec.with_tc_token(token);
}

Ok(self.client.execute(spec).await?)
}
Expand Down
3 changes: 3 additions & 0 deletions src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod contacts;
mod groups;
mod mex;
mod presence;
mod tctoken;

pub use blocking::{Blocking, BlocklistEntry};

Expand All @@ -20,3 +21,5 @@ pub use groups::{
pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse};

pub use presence::{Presence, PresenceStatus};

pub use tctoken::TcToken;
29 changes: 29 additions & 0 deletions src/features/presence.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use crate::client::Client;
use log::{debug, warn};
use wacore::StringEnum;
use wacore::iq::tctoken::build_tc_token_node;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::jid::Jid;

/// Presence status for online/offline state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
Expand Down Expand Up @@ -83,6 +85,33 @@ impl<'a> Presence<'a> {
pub async fn set_unavailable(&self) -> Result<(), anyhow::Error> {
self.set(PresenceStatus::Unavailable).await
}

/// Subscribe to a contact's presence updates.
///
/// Sends a `<presence type="subscribe">` stanza to the target JID.
/// If a valid tctoken exists for the contact, it is included as a child node.
///
/// ## Wire Format
/// ```xml
/// <presence type="subscribe" to="user@s.whatsapp.net">
/// <tctoken><!-- raw token bytes --></tctoken>
/// </presence>
/// ```
pub async fn subscribe(&self, jid: &Jid) -> Result<(), anyhow::Error> {
debug!("presence subscribe: subscribing to {}", jid);

let mut builder = NodeBuilder::new("presence")
.attr("type", "subscribe")
.attr("to", jid.to_string());

// Include tctoken if available (no t attribute, matching WhatsApp Web)
if let Some(token) = self.client.lookup_tc_token_for_jid(jid).await {
builder = builder.children([build_tc_token_node(&token)]);
}

let node = builder.build();
self.client.send_node(node).await.map_err(|e| e.into())
}
}

impl Client {
Expand Down
97 changes: 97 additions & 0 deletions src/features/tctoken.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Trusted contact privacy token feature.
//!
//! Provides high-level APIs for managing tcTokens, matching WhatsApp Web's
//! `WAWebTrustedContactsUtils` and `WAWebPrivacyTokenJob`.
//!
//! ## Usage
//! ```ignore
//! // Issue tokens to contacts
//! let tokens = client.tc_token().issue_tokens(&[jid]).await?;
//!
//! // Prune expired tokens
//! let count = client.tc_token().prune_expired().await?;
//! ```

use crate::client::Client;
use crate::request::IqError;
use wacore::iq::tctoken::{IssuePrivacyTokensSpec, ReceivedTcToken, tc_token_expiration_cutoff};
use wacore::store::traits::TcTokenEntry;
use wacore_binary::jid::Jid;

/// Feature handle for trusted contact token operations.
pub struct TcToken<'a> {
client: &'a Client,
}

impl<'a> TcToken<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}

/// Issue privacy tokens for the given contacts.
///
/// Sends an IQ to the server requesting tokens for the specified JIDs (should be LID JIDs).
/// Stores the received tokens and returns them.
pub async fn issue_tokens(&self, jids: &[Jid]) -> Result<Vec<ReceivedTcToken>, IqError> {
if jids.is_empty() {
return Ok(Vec::new());
}

let spec = IssuePrivacyTokensSpec::new(jids);
let response = self.client.execute(spec).await?;
let backend = self.client.persistence_manager.backend();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;

for received in &response.tokens {
let entry = TcTokenEntry {
token: received.token.clone(),
token_timestamp: received.timestamp,
sender_timestamp: Some(now),
};

if let Err(e) = backend.put_tc_token(&received.jid.user, &entry).await {
log::warn!(target: "Client/TcToken", "Failed to store issued tc_token for {}: {e}", received.jid);
}
}

Ok(response.tokens)
}

/// Prune expired tc tokens from the store.
///
/// Deletes all tokens older than the rolling window (28 days by default).
/// Returns the number of tokens deleted.
pub async fn prune_expired(&self) -> Result<u32, anyhow::Error> {
let backend = self.client.persistence_manager.backend();
let cutoff = tc_token_expiration_cutoff();
let deleted = backend.delete_expired_tc_tokens(cutoff).await?;

if deleted > 0 {
log::info!(target: "Client/TcToken", "Pruned {} expired tc_tokens", deleted);
}

Ok(deleted)
}

/// Get a stored tc token for a JID.
pub async fn get(&self, jid: &str) -> Result<Option<TcTokenEntry>, anyhow::Error> {
let backend = self.client.persistence_manager.backend();
Ok(backend.get_tc_token(jid).await?)
}

/// Get all JIDs that have stored tc tokens.
pub async fn get_all_jids(&self) -> Result<Vec<String>, anyhow::Error> {
let backend = self.client.persistence_manager.backend();
Ok(backend.get_all_tc_token_jids().await?)
}
}

impl Client {
/// Access trusted contact token operations.
pub fn tc_token(&self) -> TcToken<'_> {
TcToken::new(self)
}
}
123 changes: 123 additions & 0 deletions src/handlers/notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ async fn handle_notification_impl(client: &Arc<Client>, node: &Node) {
// Notifies about business account status changes: verified name, profile, removal
handle_business_notification(client, node).await;
}
"privacy_token" => {
// Handle incoming trusted contact privacy token notifications.
// Matches WhatsApp Web's WAWebHandlePrivacyTokenNotification.
handle_privacy_token_notification(client, node).await;
}
_ => {
warn!("TODO: Implement handler for <notification type='{notification_type}'>");
client
Expand Down Expand Up @@ -307,6 +312,124 @@ async fn handle_account_sync_devices(client: &Arc<Client>, node: &Node, devices_
}
}

/// Handle incoming privacy_token notification.
///
/// Stores trusted contact tokens from contacts. Matches WhatsApp Web's
/// `WAWebHandlePrivacyTokenNotification`.
///
/// Structure:
/// ```xml
/// <notification type="privacy_token" from="user@s.whatsapp.net" sender_lid="user@lid">
/// <tokens>
/// <token type="trusted_contact" t="1707000000"><!-- bytes --></token>
/// </tokens>
/// </notification>
/// ```
async fn handle_privacy_token_notification(client: &Arc<Client>, node: &Node) {
use wacore::iq::tctoken::parse_privacy_token_notification;
use wacore::store::traits::TcTokenEntry;

// Resolve the sender to a LID JID for storage.
// WA Web uses `sender_lid` attr if present, otherwise resolves from `from`.
let sender_lid = node
.attrs()
.optional_jid("sender_lid")
.map(|j| j.user.clone());

let sender_lid = match sender_lid {
Some(lid) if !lid.is_empty() => lid,
_ => {
// Fall back to resolving from the `from` JID via LID-PN cache
let from_jid = match node.attrs().optional_jid("from") {
Some(jid) => jid,
None => {
warn!(target: "Client/TcToken", "privacy_token notification missing 'from' attribute");
return;
}
};

if from_jid.is_lid() {
from_jid.user.clone()
} else {
// Try to resolve phone number to LID
match client.lid_pn_cache.get_current_lid(&from_jid.user).await {
Some(lid) => lid,
None => {
debug!(
target: "Client/TcToken",
"Cannot resolve LID for privacy_token sender {}, storing under PN",
from_jid
);
from_jid.user.clone()
}
}
}
}
};

// Parse the token data from the notification
let received_tokens = match parse_privacy_token_notification(node) {
Ok(tokens) => tokens,
Err(e) => {
warn!(target: "Client/TcToken", "Failed to parse privacy_token notification: {e}");
return;
}
};

if received_tokens.is_empty() {
debug!(target: "Client/TcToken", "privacy_token notification had no trusted_contact tokens");
return;
}

let backend = client.persistence_manager.backend();

for received in &received_tokens {
match backend.get_tc_token(&sender_lid).await {
Ok(Some(existing)) => {
// Timestamp monotonicity guard: only store if incoming >= existing
if received.timestamp < existing.token_timestamp {
debug!(
target: "Client/TcToken",
"Skipping older token for {} (incoming={}, existing={})",
sender_lid, received.timestamp, existing.token_timestamp
);
continue;
}

// Preserve existing sender_timestamp when updating token
let entry = TcTokenEntry {
token: received.token.clone(),
token_timestamp: received.timestamp,
sender_timestamp: existing.sender_timestamp,
};

if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await {
warn!(target: "Client/TcToken", "Failed to update tc_token for {}: {e}", sender_lid);
} else {
debug!(target: "Client/TcToken", "Updated tc_token for {} (t={})", sender_lid, received.timestamp);
}
}
Ok(None) => {
// New token — no existing entry
let entry = TcTokenEntry {
token: received.token.clone(),
token_timestamp: received.timestamp,
sender_timestamp: None,
};

if let Err(e) = backend.put_tc_token(&sender_lid, &entry).await {
warn!(target: "Client/TcToken", "Failed to store tc_token for {}: {e}", sender_lid);
} else {
debug!(target: "Client/TcToken", "Stored new tc_token for {} (t={})", sender_lid, received.timestamp);
}
}
Err(e) => {
warn!(target: "Client/TcToken", "Failed to read tc_token for {}: {e}, skipping", sender_lid);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/// Handle business notification (WhatsApp Web: `WAWebHandleBusinessNotification`).
async fn handle_business_notification(client: &Arc<Client>, node: &Node) {
let notification = match BusinessNotification::try_parse(node) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub use features::{
GroupCreateOptions, GroupDescription, GroupMetadata, GroupParticipant, GroupParticipantOptions,
GroupSubject, Groups, IsOnWhatsAppResult, MemberAddMode, MemberLinkMode,
MembershipApprovalMode, Mex, MexError, MexErrorExtensions, MexRequest, MexResponse,
ParticipantChangeResponse, Presence, PresenceStatus, ProfilePicture, UserInfo,
ParticipantChangeResponse, Presence, PresenceStatus, ProfilePicture, TcToken, UserInfo,
};

pub mod bot;
Expand Down
Loading