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
26 changes: 25 additions & 1 deletion src/features/contacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,31 @@ impl<'a> Contacts<'a> {
debug!("get_user_info: fetching info for {} JIDs", jids.len());

let request_id = self.client.generate_request_id();
let spec = UserInfoSpec::new(jids.to_vec(), request_id);
let mut spec = UserInfoSpec::new(jids.to_vec(), request_id);

// Attach per-user tctokens so the status/about of privacy-restricted
// contacts is returned, matching WA Web's USyncStatusProtocol.getUserElement.
if self
.client
.ab_props()
.is_enabled(wacore::iq::abprops::web::PROFILE_SCRAPING_PRIVACY_TOKEN_IN_ABOUT_USYNC)
.await
{
let lookups = futures::future::join_all(jids.iter().map(|jid| async move {
(
jid.to_non_ad().to_string(),
self.client.lookup_tc_token_for_jid(jid).await,
)
}))
.await;
let tc_tokens: HashMap<String, Vec<u8>> = lookups
.into_iter()
.filter_map(|(key, token)| token.map(|t| (key, t)))
.collect();
if !tc_tokens.is_empty() {
spec = spec.with_tc_tokens(tc_tokens);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let info = self.client.execute(spec).await?;
self.persist_lid_mappings(info.values().map(user_info_lid_pair))
Expand Down
17 changes: 16 additions & 1 deletion src/spam_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,22 @@ impl Client {
&self,
request: SpamReportRequest,
) -> Result<SpamReportResult, IqError> {
let spec = SpamReportSpec::new(request);
use wacore::iq::abprops::web;

let mut spec = SpamReportSpec::new(request);

// Attach the reported contact's tctoken so the report is accepted for a
// privacy-restricted account, matching WA Web's OutSpamTCTokenMixin.
if self
.ab_props()
.is_enabled(web::ENABLE_SPAM_REPORT_IQ_WITH_PRIVACY_TOKEN)
.await
&& let Some(reported) = spec.request.from_jid.clone()
&& let Some(token) = self.lookup_tc_token_for_jid(&reported).await
{
spec = spec.with_tc_token(token);
}

self.execute(spec).await
}
}
Expand Down
51 changes: 47 additions & 4 deletions wacore/src/iq/spam_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
//! ```

use crate::iq::spec::IqSpec;
use crate::iq::tctoken::build_tc_token_node;
use crate::request::InfoQuery;
use crate::types::spam_report::{SpamReportRequest, SpamReportResult, build_spam_list_node};
use wacore_binary::{Jid, Server};
use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
use wacore_binary::{Node, NodeContent, NodeContentRef, NodeRef};

// Re-export types for convenience
pub use crate::types::spam_report::{
Expand All @@ -32,24 +33,39 @@ pub use crate::types::spam_report::{
#[derive(Debug, Clone)]
pub struct SpamReportSpec {
pub request: SpamReportRequest,
/// Optional trusted-contact token for the reported contact, matching WA
/// Web's `OutSpamTCTokenMixin` (gated on `enable_spam_report_iq_with_privacy_token`).
pub tc_token: Option<Vec<u8>>,
}

impl SpamReportSpec {
pub fn new(request: SpamReportRequest) -> Self {
Self { request }
Self {
request,
tc_token: None,
}
}

/// Include the reported contact's tctoken in the spam report IQ.
pub fn with_tc_token(mut self, token: Vec<u8>) -> Self {
self.tc_token = Some(token);
self
}
}

impl IqSpec for SpamReportSpec {
type Response = SpamReportResult;

fn build_iq(&self) -> InfoQuery<'static> {
let spam_list_node = build_spam_list_node(&self.request);
let mut children: Vec<Node> = vec![build_spam_list_node(&self.request)];
if let Some(token) = &self.tc_token {
children.push(build_tc_token_node(token));
}

InfoQuery::set(
"spam",
Jid::new("", Server::Pn),
Some(NodeContent::Nodes(vec![spam_list_node])),
Some(NodeContent::Nodes(children)),
)
}

Expand Down Expand Up @@ -101,6 +117,33 @@ mod tests {
}
}

#[test]
fn test_spam_report_spec_build_iq_with_tctoken() {
let request = SpamReportRequest {
message_id: "TEST123".to_string(),
message_timestamp: 1234567890,
spam_flow: SpamFlow::MessageMenu,
..Default::default()
};

let iq = SpamReportSpec::new(request)
.with_tc_token(vec![0xCA, 0xFE])
.build_iq();

let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("Expected NodeContent::Nodes");
};
assert!(nodes.iter().any(|n| n.tag == "spam_list"));
let tctoken = nodes
.iter()
.find(|n| n.tag == "tctoken")
.expect("spam report should carry a tctoken when set");
match &tctoken.content {
Some(NodeContent::Bytes(b)) => assert_eq!(b, &[0xCA, 0xFE]),
_ => panic!("tctoken should carry bytes"),
}
}

#[test]
fn test_spam_report_spec_parse_response_with_report_id() {
let request = SpamReportRequest {
Expand Down
59 changes: 56 additions & 3 deletions wacore/src/iq/usync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

use crate::WireEnum;
use crate::iq::spec::IqSpec;
use crate::iq::tctoken::build_tc_token_node;
use crate::request::InfoQuery;
use crate::stanza::business::VerifiedName;
use anyhow::anyhow;
Expand Down Expand Up @@ -500,15 +501,29 @@ impl IqSpec for IsOnWhatsAppSpec {
pub struct UserInfoSpec {
pub jids: Vec<Jid>,
pub sid: String,
/// Per-user trusted-contact token, keyed by the query JID's non-ad string
/// form (domain-qualified, so PN and LID forms don't collide), attached to
/// the matching `<user>` node for privacy-gated subprotocols (status/about),
/// matching WA Web's `USyncStatusProtocol.getUserElement` /
/// `USyncUser.withTcToken`.
pub tc_tokens: HashMap<String, Vec<u8>>,
}

impl UserInfoSpec {
pub fn new(jids: Vec<Jid>, sid: impl Into<String>) -> Self {
Self {
jids,
sid: sid.into(),
tc_tokens: HashMap::new(),
}
}

/// Attach per-user trusted-contact tokens keyed by the query JID's non-ad
/// string form (see [`UserInfoSpec::tc_tokens`]).
pub fn with_tc_tokens(mut self, tc_tokens: HashMap<String, Vec<u8>>) -> Self {
self.tc_tokens = tc_tokens;
self
}
}

impl IqSpec for UserInfoSpec {
Expand All @@ -531,9 +546,12 @@ impl IqSpec for UserInfoSpec {
.jids
.iter()
.map(|jid| {
NodeBuilder::new("user")
.attr("jid", jid.to_non_ad())
.build()
let key = jid.to_non_ad().to_string();
let mut builder = NodeBuilder::new("user").attr("jid", key.clone());
if let Some(token) = self.tc_tokens.get(&key) {
builder = builder.children([build_tc_token_node(token)]);
}
builder.build()
})
.collect();

Expand Down Expand Up @@ -1355,6 +1373,41 @@ mod tests {
assert_eq!(info.business_error.as_ref().unwrap().code, Some(406));
}

#[test]
fn user_info_attaches_per_user_tctoken() {
let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let mut tokens = HashMap::new();
tokens.insert(jid.to_non_ad().to_string(), vec![0xDE, 0xAD]);
let spec = UserInfoSpec::new(vec![jid], "sid").with_tc_tokens(tokens);

let iq = spec.build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected usync nodes");
};
let list = nodes[0].get_children_by_tag("list").next().unwrap();
let user = list.get_children_by_tag("user").next().unwrap();
let tctoken = user
.get_children_by_tag("tctoken")
.next()
.expect("user node should carry a tctoken");
match &tctoken.content {
Some(NodeContent::Bytes(b)) => assert_eq!(b, &[0xDE, 0xAD]),
_ => panic!("tctoken should carry bytes"),
}
}

#[test]
fn user_info_without_tctoken_omits_it() {
let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
let iq = UserInfoSpec::new(vec![jid], "sid").build_iq();
let Some(NodeContent::Nodes(nodes)) = &iq.content else {
panic!("expected usync nodes");
};
let list = nodes[0].get_children_by_tag("list").next().unwrap();
let user = list.get_children_by_tag("user").next().unwrap();
assert!(user.get_children_by_tag("tctoken").next().is_none());
}

#[test]
fn user_info_result_subprotocol_error_is_rejected() {
let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
Expand Down
Loading