soblendwa 0.1.0

SoblendWA: a blazing fast WhatsApp Web multi-device client library written in Rust. Crafted by Soblend Development Studio.
Documentation
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use prost::Message as _;
use serde_json::{json, Value};

use super::Client;
use crate::binary::Node;
use crate::error::{Error, Result};
use crate::jid::Jid;
use crate::proto::Message;

mod query_id {
    pub const CREATE: &str = "8823471724422422";
    pub const UPDATE: &str = "24250201037901610";
    pub const METADATA: &str = "6563316087068696";
    pub const SUBSCRIBERS: &str = "9783111038412085";
    pub const FOLLOW: &str = "24404358912487870";
    pub const UNFOLLOW: &str = "9767147403369991";
    pub const MUTE: &str = "29766401636284406";
    pub const UNMUTE: &str = "9864994326891137";
    pub const ADMIN_COUNT: &str = "7130823597031706";
    pub const CHANGE_OWNER: &str = "7341777602580933";
    pub const DEMOTE: &str = "6551828931592903";
    pub const DELETE: &str = "30062808666639665";
}

#[derive(Clone, Debug, Default)]
pub struct NewsletterInfo {
    pub jid: Jid,
    pub name: Option<String>,
    pub description: Option<String>,
    pub invite: Option<String>,
    pub subscribers: Option<u64>,
    pub verified: bool,
    pub picture_path: Option<String>,
    pub muted: bool,
    pub role: Option<String>,
    pub created: Option<i64>,
    pub raw: Value,
}

impl NewsletterInfo {
    fn parse(value: Value) -> Option<Self> {
        let root = if value.get("id").is_some() { &value } else { value.get("result")? };
        let thread = &root["thread_metadata"];
        let viewer = &root["viewer_metadata"];
        let text = |v: &Value| v.get("text").and_then(Value::as_str).map(str::to_owned);
        let number = |v: &Value| v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_u64());
        Some(NewsletterInfo {
            jid: root["id"].as_str()?.parse().ok()?,
            name: text(&thread["name"]),
            description: text(&thread["description"]),
            invite: thread["invite"].as_str().map(str::to_owned),
            subscribers: number(&thread["subscribers_count"]),
            verified: thread["verification"].as_str() == Some("VERIFIED"),
            picture_path: thread["picture"]["direct_path"].as_str().map(str::to_owned),
            muted: viewer["mute"].as_str() == Some("ON"),
            role: viewer["role"].as_str().map(str::to_owned),
            created: number(&thread["creation_time"]).map(|n| n as i64),
            raw: value.clone(),
        })
    }
}

#[derive(Clone, Debug)]
pub struct NewsletterMessage {
    pub server_id: String,
    pub id: Option<String>,
    pub timestamp: i64,
    pub views: Option<u64>,
    pub message: Option<Message>,
    pub reactions: Vec<(String, u64)>,
}

impl Client {
    pub(super) async fn mex(&self, query_id: &'static str, variables: Value, path: &str) -> Result<Value> {
        let body = serde_json::to_vec(&json!({ "variables": variables }))?;
        let request = Node::new("iq")
            .attr("to", Jid::server())
            .attr("type", "get")
            .attr("xmlns", "w:mex")
            .child_node(Node::new("query").attr("query_id", query_id).bytes(body));
        let response = self.query(request).await?;
        let raw = response
            .child("result")
            .and_then(Node::content_bytes)
            .ok_or_else(|| Error::other("graphql answer without a result"))?;
        let data: Value = serde_json::from_slice(raw)?;
        if let Some(errors) = data["errors"].as_array().filter(|e| !e.is_empty()) {
            let text = errors.iter().filter_map(|e| e["message"].as_str()).collect::<Vec<_>>().join(", ");
            let code = errors[0]["extensions"]["error_code"].as_u64().unwrap_or(400) as u16;
            return Err(Error::Server { code, text });
        }
        let payload = if path.is_empty() { &data["data"] } else { &data["data"][path] };
        if payload.is_null() {
            return Err(Error::other(format!("graphql answer for {path} is empty")));
        }
        Ok(payload.clone())
    }

    pub async fn newsletter_create(&self, name: &str, description: Option<&str>) -> Result<NewsletterInfo> {
        let variables = json!({ "input": { "name": name, "description": description } });
        let raw = self.mex(query_id::CREATE, variables, "xwa2_newsletter_create").await?;
        NewsletterInfo::parse(raw).ok_or_else(|| Error::other("unexpected newsletter create answer"))
    }

    pub async fn newsletter_info(&self, jid: &Jid) -> Result<NewsletterInfo> {
        self.newsletter_lookup("JID", &jid.to_string()).await
    }

    pub async fn newsletter_info_by_invite(&self, invite: &str) -> Result<NewsletterInfo> {
        let code = invite.rsplit('/').next().unwrap_or(invite);
        self.newsletter_lookup("INVITE", code).await
    }

    async fn newsletter_lookup(&self, kind: &str, key: &str) -> Result<NewsletterInfo> {
        let variables = json!({
            "fetch_creation_time": true,
            "fetch_full_image": true,
            "fetch_viewer_metadata": true,
            "input": { "key": key, "type": kind }
        });
        let raw = self.mex(query_id::METADATA, variables, "xwa2_newsletter").await?;
        NewsletterInfo::parse(raw).ok_or_else(|| Error::other("unexpected newsletter metadata answer"))
    }

    async fn newsletter_action(&self, jid: &Jid, id: &'static str, path: &str) -> Result<()> {
        self.mex(id, json!({ "newsletter_id": jid.to_string() }), path).await.map(drop)
    }

    pub async fn newsletter_follow(&self, jid: &Jid) -> Result<()> {
        self.newsletter_action(jid, query_id::FOLLOW, "xwa2_newsletter_join_v2").await
    }

    pub async fn newsletter_unfollow(&self, jid: &Jid) -> Result<()> {
        self.newsletter_action(jid, query_id::UNFOLLOW, "xwa2_newsletter_leave_v2").await
    }

    pub async fn newsletter_mute(&self, jid: &Jid, muted: bool) -> Result<()> {
        match muted {
            true => self.newsletter_action(jid, query_id::MUTE, "xwa2_newsletter_mute_v2").await,
            false => self.newsletter_action(jid, query_id::UNMUTE, "xwa2_newsletter_unmute_v2").await,
        }
    }

    pub async fn newsletter_delete(&self, jid: &Jid) -> Result<()> {
        self.newsletter_action(jid, query_id::DELETE, "xwa2_newsletter_delete_v2").await
    }

    pub async fn newsletter_subscribers(&self, jid: &Jid) -> Result<u64> {
        let raw = self
            .mex(query_id::SUBSCRIBERS, json!({ "newsletter_id": jid.to_string() }), "xwa2_newsletter_subscribers")
            .await?;
        Ok(raw["subscribers"].as_u64().or_else(|| raw["subscribers"].as_str()?.parse().ok()).unwrap_or_default())
    }

    pub async fn newsletter_admin_count(&self, jid: &Jid) -> Result<u64> {
        let raw = self
            .mex(query_id::ADMIN_COUNT, json!({ "newsletter_id": jid.to_string() }), "xwa2_newsletter_admin")
            .await?;
        Ok(raw["admin_count"].as_u64().unwrap_or_default())
    }

    pub async fn newsletter_change_owner(&self, jid: &Jid, new_owner: &Jid) -> Result<()> {
        let variables = json!({ "newsletter_id": jid.to_string(), "user_id": new_owner.to_non_device().to_string() });
        self.mex(query_id::CHANGE_OWNER, variables, "xwa2_newsletter_change_owner").await.map(drop)
    }

    pub async fn newsletter_demote(&self, jid: &Jid, admin: &Jid) -> Result<()> {
        let variables = json!({ "newsletter_id": jid.to_string(), "user_id": admin.to_non_device().to_string() });
        self.mex(query_id::DEMOTE, variables, "xwa2_newsletter_demote").await.map(drop)
    }

    pub async fn newsletter_update(
        &self,
        jid: &Jid,
        name: Option<&str>,
        description: Option<&str>,
        picture: Option<Vec<u8>>,
    ) -> Result<()> {
        let mut updates = serde_json::Map::new();
        if let Some(name) = name {
            updates.insert("name".into(), json!(name));
        }
        if let Some(description) = description {
            updates.insert("description".into(), json!(description));
        }
        if let Some(image) = picture {
            let encoded = if image.is_empty() {
                String::new()
            } else {
                let jpeg = tokio::task::spawn_blocking(move || super::enrich::profile_picture(&image))
                    .await
                    .map_err(|err| Error::Media(err.to_string()))??;
                STANDARD.encode(jpeg)
            };
            updates.insert("picture".into(), json!(encoded));
        }
        updates.insert("settings".into(), Value::Null);
        let variables = json!({ "newsletter_id": jid.to_string(), "updates": updates });
        self.mex(query_id::UPDATE, variables, "xwa2_newsletter_update").await.map(drop)
    }

    pub async fn newsletter_react(&self, jid: &Jid, server_id: &str, reaction: Option<&str>) -> Result<()> {
        let mut stanza = Node::new("message")
            .attr("to", jid.clone())
            .attr("type", "reaction")
            .attr("server_id", server_id.to_owned())
            .attr("id", self.generate_tag());
        if reaction.is_none() {
            stanza.set_attr("edit", "7");
        }
        stanza.push_child(Node::new("reaction").attr_opt("code", reaction.map(str::to_owned)));
        self.send_node(&stanza)
    }

    pub async fn newsletter_messages(
        &self,
        jid: &Jid,
        count: u32,
        before: Option<u64>,
    ) -> Result<Vec<NewsletterMessage>> {
        let request =
            Node::new("iq").attr("to", Jid::server()).attr("type", "get").attr("xmlns", "newsletter").child_node(
                Node::new("messages")
                    .attr("type", "jid")
                    .attr("jid", jid.clone())
                    .attr("count", count)
                    .attr_opt("before", before),
            );
        let response = self.query(request).await?;
        let list = response.child("messages").map(Node::nodes).unwrap_or_default();
        Ok(list
            .iter()
            .filter(|n| n.tag == "message")
            .filter_map(|n| {
                Some(NewsletterMessage {
                    server_id: n.attr_string("server_id")?,
                    id: n.attr_string("id"),
                    timestamp: n.attr_i64("t").unwrap_or_default(),
                    views: n.child("views_count").and_then(|v| v.attr_u64("count")),
                    message: n.child_bytes("plaintext").and_then(|raw| Message::decode(raw).ok()),
                    reactions: n
                        .child("reactions")
                        .map(|r| {
                            r.children_by("reaction")
                                .filter_map(|x| Some((x.attr_string("code")?, x.attr_u64("count").unwrap_or(0))))
                                .collect()
                        })
                        .unwrap_or_default(),
                })
            })
            .collect())
    }

    pub async fn newsletter_live_updates(&self, jid: &Jid) -> Result<Option<u64>> {
        let request = Node::new("iq")
            .attr("to", jid.clone())
            .attr("type", "set")
            .attr("xmlns", "newsletter")
            .child_node(Node::new("live_updates"));
        let response = self.query(request).await?;
        Ok(response.child("live_updates").and_then(|n| n.attr_u64("duration")))
    }
}

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

    #[test]
    fn parses_newsletter_metadata() {
        let raw = json!({
            "id": "120363000000000001@newsletter",
            "thread_metadata": {
                "name": { "text": "Soblend" },
                "description": { "text": "Canal oficial" },
                "invite": "AbCdEf",
                "subscribers_count": "1500",
                "verification": "VERIFIED",
                "creation_time": "1700000000",
                "picture": { "direct_path": "/v/t61/abc" }
            },
            "viewer_metadata": { "mute": "OFF", "role": "OWNER" }
        });
        let info = NewsletterInfo::parse(raw).unwrap();
        assert_eq!(info.jid.to_string(), "120363000000000001@newsletter");
        assert_eq!(info.name.as_deref(), Some("Soblend"));
        assert_eq!(info.subscribers, Some(1500));
        assert!(info.verified && !info.muted);
        assert_eq!(info.role.as_deref(), Some("OWNER"));
        assert_eq!(info.created, Some(1_700_000_000));
    }
}