steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Mobile confirmation services.

use steam_totp::{generate_confirmation_key, generate_device_id, Secret};

use crate::{client::SteamUser, endpoint::steam_endpoint, error::SteamUserError, types::Confirmation};

impl SteamUser {
    /// Retrieves a list of outstanding mobile confirmations.
    ///
    /// Fetches the confirmation list from `https://steamcommunity.com/mobileconf/getlist`.
    ///
    /// # Arguments
    ///
    /// * `identity_secret` - The identity secret for the account (base64
    ///   encoded).
    /// * `tag` - Optional tag for the request (defaults to "conf").
    ///
    /// # Returns
    ///
    /// Returns a `Vec<Confirmation>` containing all pending confirmations.
    ///
    /// # Errors
    ///
    /// - Returns [`SteamUserError::SessionExpired`] if the session is no longer
    ///   valid.
    /// - Returns [`SteamUserError::SteamError`] if the request fails on the
    ///   Steam side.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let identity_secret = "your_base64_identity_secret";
    /// let confs = user.get_confirmations(identity_secret, None).await?;
    /// for conf in confs {
    ///     println!("Confirmation: {} (Title: {})", conf.id, conf.title);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(GET, host = Community, path = "/mobileconf/getlist", kind = Auth)]
    pub async fn get_confirmations(&self, identity_secret: &str, tag: Option<&str>) -> Result<Vec<Confirmation>, SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let device_id = generate_device_id(steam_id, None);

        let time = i64::try_from(
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)?
                .as_secs(),
        )
        .unwrap_or(0);

        let tag = tag.unwrap_or("conf");
        let secret = Secret::from_string(identity_secret)?;
        let key = generate_confirmation_key(&secret, time, tag)?;

        let time_str = time.to_string();
        let steam_id_str = steam_id.steam_id64().to_string();

        let params = [("p", device_id.as_str()), ("a", steam_id_str.as_str()), ("k", key.as_str()), ("t", time_str.as_str()), ("m", "react"), ("tag", tag)];

        // "conf" is used for getlist in node library (legacy tag "list" also works but
        // "conf" is safer)
        let response: serde_json::Value = self.get_path("/mobileconf/getlist").query(&params).send().await?.json().await?;

        if !response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
            let message = response.get("message").or_else(|| response.get("detail")).and_then(|v| v.as_str()).unwrap_or("Failed to get confirmation list").to_string();

            if response.get("needauth").and_then(|v| v.as_bool()).unwrap_or(false) {
                return Err(SteamUserError::SessionExpired);
            }

            return Err(SteamUserError::SteamError(message));
        }

        let confs = response
            .get("conf")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| match Confirmation::from_api(v) {
                        Ok(c) => Some(c),
                        Err(e) => {
                            tracing::warn!(error = %e, "skipping malformed confirmation entry");
                            None
                        }
                    })
                    .collect()
            })
            .unwrap_or_default();

        Ok(confs)
    }

    /// Responds to a single confirmation by either accepting or canceling it.
    ///
    /// # Arguments
    ///
    /// * `confirmation` - The [`Confirmation`] to respond to.
    /// * `identity_secret` - The identity secret for the account (base64
    ///   encoded).
    /// * `accept` - `true` to accept, `false` to cancel/deny.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// # let conf = todo!();
    /// # let identity_secret = "secret";
    /// user.respond_to_confirmation(&conf, identity_secret, true)
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    // delegates to `respond_to_confirmations` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, identity_secret), fields(accept))]
    pub async fn respond_to_confirmation(&self, confirmation: &Confirmation, identity_secret: &str, accept: bool) -> Result<(), SteamUserError> {
        self.respond_to_confirmations(std::slice::from_ref(confirmation), identity_secret, accept).await
    }

    /// Responds to multiple confirmations at once.
    ///
    /// Sends a POST request to `https://steamcommunity.com/mobileconf/multiajaxop`.
    ///
    /// # Arguments
    ///
    /// * `confirmations` - A slice of [`Confirmation`] structs to respond to.
    /// * `identity_secret` - The identity secret for the account (base64
    ///   encoded).
    /// * `accept` - `true` to accept all, `false` to cancel/deny all.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// # let confs = vec![];
    /// # let identity_secret = "secret";
    /// user.respond_to_confirmations(&confs, identity_secret, true)
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(POST, host = Community, path = "/mobileconf/multiajaxop", kind = Auth)]
    pub async fn respond_to_confirmations(&self, confirmations: &[Confirmation], identity_secret: &str, accept: bool) -> Result<(), SteamUserError> {
        if confirmations.is_empty() {
            return Ok(());
        }

        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let device_id = generate_device_id(steam_id, None);

        let time = i64::try_from(
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)?
                .as_secs(),
        )
        .unwrap_or(0);

        let tag = if accept { "allow" } else { "cancel" };
        let secret = Secret::from_string(identity_secret)?;
        let key = generate_confirmation_key(&secret, time, tag)?;

        let op = if accept { "allow" } else { "cancel" };

        let time_str = time.to_string();
        let steam_id_str = steam_id.steam_id64().to_string();

        let params = [("p", device_id.as_str()), ("a", steam_id_str.as_str()), ("k", key.as_str()), ("t", time_str.as_str()), ("m", "react"), ("tag", tag), ("op", op)];

        let mut form_params = Vec::new();
        for (k, v) in params.iter() {
            form_params.push((*k, *v));
        }

        // Add cid and ck arrays
        for conf in confirmations {
            form_params.push(("cid[]", conf.id.as_str()));
            form_params.push(("ck[]", conf.key.as_str()));
        }

        let response: serde_json::Value = self.post_path("/mobileconf/multiajaxop").form(&form_params).send().await?.json().await?;

        if response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
            Ok(())
        } else {
            let message = response.get("message").and_then(|v| v.as_str()).unwrap_or("Could not act on confirmation").to_string();
            Err(SteamUserError::SteamError(message))
        }
    }

    /// Retrieves the trade offer ID or market listing ID associated with a
    /// confirmation.
    ///
    /// Scrapes the confirmation details page at `https://steamcommunity.com/mobileconf/detailspage/<id>`.
    ///
    /// # Arguments
    ///
    /// * `confirmation` - The [`Confirmation`] to get details for.
    /// * `identity_secret` - The identity secret for the account (base64
    ///   encoded).
    ///
    /// # Returns
    ///
    /// Returns `Ok(Some(String))` containing the object ID if found.
    #[steam_endpoint(GET, host = Community, path = "/mobileconf/detailspage/{confirmation_id}", kind = Auth)]
    pub async fn get_confirmation_offer_id(&self, confirmation: &Confirmation, identity_secret: &str) -> Result<Option<String>, SteamUserError> {
        if let Some(offer_id) = confirmation.offer_id() {
            return Ok(Some(offer_id.to_string()));
        }

        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        let device_id = generate_device_id(steam_id, None);

        let time = i64::try_from(
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)?
                .as_secs(),
        )
        .unwrap_or(0);

        let secret = Secret::from_string(identity_secret)?;
        let key = generate_confirmation_key(&secret, time, "details")?;

        let time_str = time.to_string();
        let steam_id_str = steam_id.steam_id64().to_string();

        let params = [("p", device_id.as_str()), ("a", steam_id_str.as_str()), ("k", key.as_str()), ("t", time_str.as_str()), ("m", "react"), ("tag", "details")];

        let response = self.get_path(format!("/mobileconf/detailspage/{}", confirmation.id)).query(&params).send().await?.text().await?;

        // Parse HTML to find tradeoffer id
        use scraper::{Html, Selector};
        let document = Html::parse_document(&response);
        let selector = Selector::parse(".tradeoffer").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;

        if let Some(element) = document.select(&selector).next() {
            if let Some(id_attr) = element.value().attr("id") {
                // Format is usually tradeofferid_12345
                if let Some(parts) = id_attr.split('_').nth(1) {
                    return Ok(Some(parts.to_string()));
                }
            }
        }

        Ok(None)
    }

    /// Performs an action (accept or deny) for a specific confirmation object
    /// ID.
    ///
    /// This method first fetches the confirmation list to find the one matching
    /// the `object_id`.
    ///
    /// # Arguments
    ///
    /// * `identity_secret` - The identity secret for the account (base64
    ///   encoded).
    /// * `object_id` - The unique identifier for the confirmation object (trade
    ///   offer ID or market listing ID).
    /// * `accept` - `true` to accept, `false` to deny.
    ///
    /// # Errors
    ///
    /// Returns [`SteamUserError::ConfirmationNotFound`] if no confirmation
    /// matches the ID.
    // delegates to `get_confirmations` + `respond_to_confirmation` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id, accept = accept))]
    pub async fn perform_confirmation_action(&self, identity_secret: &str, object_id: u64, accept: bool) -> Result<(), SteamUserError> {
        let tag = if accept { "list" } else { "conf" };
        let confirmations = self.get_confirmations(identity_secret, Some(tag)).await?;

        let object_id_str = object_id.to_string();
        let confirmation = confirmations.iter().find(|c| c.creator == object_id_str).ok_or(SteamUserError::ConfirmationNotFound(object_id))?;

        self.respond_to_confirmation(confirmation, identity_secret, accept).await
    }

    /// Accepts a confirmation for a specific object (trade offer ID or market
    /// listing ID).
    ///
    /// Convenience wrapper around [`perform_confirmation_action`].
    // delegates to `perform_confirmation_action` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id))]
    pub async fn accept_confirmation_for_object(&self, identity_secret: &str, object_id: u64) -> Result<(), SteamUserError> {
        self.perform_confirmation_action(identity_secret, object_id, true).await
    }

    /// Denies a confirmation for a specific object (trade offer ID or market
    /// listing ID).
    ///
    /// Convenience wrapper around [`perform_confirmation_action`].
    // delegates to `perform_confirmation_action` — no #[steam_endpoint]
    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id))]
    pub async fn deny_confirmation_for_object(&self, identity_secret: &str, object_id: u64) -> Result<(), SteamUserError> {
        self.perform_confirmation_action(identity_secret, object_id, false).await
    }
}