soblendwa 0.1.0

SoblendWA: a blazing fast WhatsApp Web multi-device client library written in Rust. Crafted by Soblend Development Studio.
Documentation
use std::sync::atomic::{AtomicBool, Ordering};

use prost::Message as _;

use super::Client;
use crate::binary::Node;
use crate::error::Result;
use crate::jid::Jid;
use crate::message::now_secs;
use crate::proto::StoredTcToken;
use crate::store::KeyKind;

const BUCKET_SECONDS: i64 = 604_800;
const BUCKETS: i64 = 4;

pub(crate) struct ServerProps {
    pub privacy_token_on_direct: AtomicBool,
    pub privacy_token_on_profile_picture: AtomicBool,
    pub issue_tokens_to_lid: AtomicBool,
}

impl Default for ServerProps {
    fn default() -> Self {
        ServerProps {
            privacy_token_on_direct: AtomicBool::new(true),
            privacy_token_on_profile_picture: AtomicBool::new(true),
            issue_tokens_to_lid: AtomicBool::new(false),
        }
    }
}

fn expired(timestamp: i64) -> bool {
    let cutoff = (now_secs() / BUCKET_SECONDS - (BUCKETS - 1)) * BUCKET_SECONDS;
    timestamp < cutoff
}

fn needs_fresh_issue(issued_at: Option<i64>) -> bool {
    issued_at.is_none_or(|issued| now_secs() / BUCKET_SECONDS > issued / BUCKET_SECONDS)
}

fn is_regular_user(jid: &Jid) -> bool {
    let bot = (jid.user.len() == 11 && jid.user.starts_with("1313555"))
        || (jid.user.len() == 11 && jid.user.starts_with("131655500"));
    jid.is_user() && jid.user != "0" && !bot
}

fn flag(value: Option<&str>) -> Option<bool> {
    value.map(|v| v == "true" || v == "1")
}

impl Client {
    pub(crate) async fn fetch_server_props(&self) -> Result<()> {
        let request = Node::new("iq")
            .attr("to", Jid::server())
            .attr("type", "get")
            .attr("xmlns", "abt")
            .child_node(Node::new("props").attr("protocol", "1"));
        let response = self.query(request).await?;
        let Some(props) = response.child("props") else { return Ok(()) };
        let lookup = |code: &str, name: &str| {
            props.children_by("prop").find_map(|prop| {
                let key = prop.attr_str("name").or_else(|| prop.attr_str("config_code"))?;
                (key == code || key == name)
                    .then(|| prop.attr_str("value").or_else(|| prop.attr_str("config_value")))?
            })
        };
        let settings = &self.inner.server_props;
        if let Some(on) = flag(lookup("10518", "privacy_token_sending_on_all_1_on_1_messages")) {
            settings.privacy_token_on_direct.store(on, Ordering::Relaxed);
        }
        if let Some(on) = flag(lookup("9666", "profile_scraping_privacy_token_in_photo_iq")) {
            settings.privacy_token_on_profile_picture.store(on, Ordering::Relaxed);
        }
        if let Some(on) = flag(lookup("14303", "lid_trusted_token_issue_to_lid")) {
            settings.issue_tokens_to_lid.store(on, Ordering::Relaxed);
        }
        Ok(())
    }

    async fn tc_storage_jid(&self, jid: &Jid) -> Jid {
        let user = jid.to_non_device();
        if user.is_lid() {
            return user;
        }
        self.lid_for_phone(&user).await.ok().flatten().unwrap_or(user)
    }

    async fn load_tc_token(&self, storage: &Jid) -> Option<StoredTcToken> {
        let raw = self.inner.store.get(KeyKind::TcToken, &storage.to_string()).await.ok()??;
        StoredTcToken::decode(raw.as_slice()).ok()
    }

    async fn save_tc_token(&self, storage: &Jid, entry: &StoredTcToken) -> Result<()> {
        self.inner.store.set(KeyKind::TcToken, &storage.to_string(), entry.encode_to_vec()).await
    }

    async fn tc_token_for(&self, jid: &Jid) -> Option<Vec<u8>> {
        let entry = self.load_tc_token(&self.tc_storage_jid(jid).await).await?;
        (!entry.token.is_empty() && !expired(entry.timestamp)).then_some(entry.token)
    }

    pub(crate) async fn direct_tc_token_node(&self, chat: &Jid) -> Option<Node> {
        if !self.inner.server_props.privacy_token_on_direct.load(Ordering::Relaxed) {
            return None;
        }
        self.tc_token_for(chat).await.map(|token| Node::new("tctoken").bytes(token))
    }

    pub(crate) async fn profile_tc_token_node(&self, jid: &Jid) -> Option<Node> {
        if !self.inner.server_props.privacy_token_on_profile_picture.load(Ordering::Relaxed)
            || !jid.is_user()
            || self.is_me(jid)
        {
            return None;
        }
        self.tc_token_for(jid).await.map(|token| Node::new("tctoken").bytes(token))
    }

    pub(crate) async fn on_privacy_token_notification(&self, node: &Node) -> Result<()> {
        let (Some(tokens), Some(from)) = (node.child("tokens"), node.attr_jid("from")) else { return Ok(()) };
        let storage = match node.attr_jid("sender_lid").filter(Jid::is_lid) {
            Some(lid) => lid.to_non_device(),
            None => self.tc_storage_jid(&from).await,
        };
        self.store_tc_tokens(tokens, &storage).await
    }

    async fn store_tc_tokens(&self, tokens: &Node, storage: &Jid) -> Result<()> {
        if !is_regular_user(storage) {
            return Ok(());
        }
        for token in tokens.children_by("token").filter(|t| t.attr_is("type", "trusted_contact")) {
            let (Some(bytes), Some(incoming)) = (token.content_bytes(), token.attr_i64("t").filter(|t| *t > 0)) else {
                continue;
            };
            let mut entry = self.load_tc_token(storage).await.unwrap_or_default();
            if entry.timestamp > incoming {
                continue;
            }
            entry.token = bytes.to_vec();
            entry.timestamp = incoming;
            self.save_tc_token(storage, &entry).await?;
        }
        Ok(())
    }

    pub(crate) fn issue_tc_token_later(&self, chat: Jid) {
        let client = self.clone();
        tokio::spawn(async move {
            if let Err(err) = client.issue_tc_token(&chat).await {
                tracing::debug!(%chat, %err, "privacy token issuance failed");
            }
        });
    }

    async fn issue_tc_token(&self, chat: &Jid) -> Result<()> {
        let storage = self.tc_storage_jid(chat).await;
        if !is_regular_user(&storage) {
            return Ok(());
        }
        let current = self.load_tc_token(&storage).await.unwrap_or_default();
        if !needs_fresh_issue(current.sender_timestamp) || !self.inner.issuing_tokens.insert(storage.clone()) {
            return Ok(());
        }
        let target = if self.inner.server_props.issue_tokens_to_lid.load(Ordering::Relaxed) {
            storage.clone()
        } else if chat.is_lid() {
            self.phone_for_lid(chat).await.ok().flatten().unwrap_or_else(|| chat.to_non_device())
        } else {
            chat.to_non_device()
        };
        let issued_at = now_secs();
        let request =
            Node::new("iq").attr("to", Jid::server()).attr("type", "set").attr("xmlns", "privacy").child_node(
                Node::new("tokens").child_node(
                    Node::new("token").attr("jid", target).attr("t", issued_at).attr("type", "trusted_contact"),
                ),
            );
        let result = self.query(request).await;
        self.inner.issuing_tokens.remove(&storage);
        let response = result?;
        if let Some(tokens) = response.child("tokens") {
            self.store_tc_tokens(tokens, &storage).await?;
        }
        let mut entry = self.load_tc_token(&storage).await.unwrap_or_default();
        entry.sender_timestamp = Some(issued_at);
        self.save_tc_token(&storage, &entry).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn token_windows_follow_whatsapp_buckets() {
        assert!(!expired(now_secs()));
        assert!(!expired(now_secs() - 14 * 86_400));
        assert!(expired(now_secs() - 40 * 86_400));
        assert!(needs_fresh_issue(None));
        assert!(!needs_fresh_issue(Some(now_secs())));
        assert!(needs_fresh_issue(Some(now_secs() - 8 * 86_400)));
    }

    #[test]
    fn bots_and_psa_are_not_regular_users() {
        assert!(is_regular_user(&Jid::phone("5215512345678")));
        assert!(!is_regular_user(&Jid::phone("0")));
        assert!(!is_regular_user(&Jid::phone("13135550002")));
        assert!(!is_regular_user(&Jid::group("1203630")));
    }
}