steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Steam Web API key management.

use scraper::{Html, Selector};
use steamid::SteamID;

use crate::{
    client::SteamUser,
    endpoint::steam_endpoint,
    error::SteamUserError,
    types::{OnlineState, PrivacyState, PublicProfileSummary, TradeBanState},
};

impl SteamUser {
    /// Retrieves the current Steam Web API key, registering one if none exists.
    ///
    /// # Arguments
    ///
    /// * `domain` - The domain name to register (e.g., "localhost").
    #[steam_endpoint(GET, host = Community, path = "/dev/apikey", kind = Read)]
    pub async fn get_web_api_key(&self, domain: &str) -> Result<String, SteamUserError> {
        let response = self.get_path("/dev/apikey").send().await?;
        self.check_response(&response)?;
        let html = response.text().await?;

        if html.contains("You will be granted access to Steam Web API keys when you have games in your Steam account.") {
            return Err(SteamUserError::LimitedAccount("Account needs games to register API key".into()));
        }

        let key = parse_web_api_key(&html);
        if let Some(k) = key {
            return Ok(k);
        }

        // Check if we need to register
        let document = Html::parse_document(&html);
        let form_selector = Selector::parse("form[action*='/dev/registerkey']").map_err(|_| SteamUserError::Other("Failed to parse selector".into()))?;

        if document.select(&form_selector).next().is_some() {
            // Register
            let response = self.post_path("/dev/registerkey").form(&[("domain", domain), ("agreeToTerms", "agreed"), ("Submit", "Register")]).send().await?;
            self.check_response(&response)?;

            let html = response.text().await?;
            // JS implementation recurses or re-parses. We just re-parse.
            if let Some(k) = parse_web_api_key(&html) {
                return Ok(k);
            }

            // Check for specific registration errors if possible, or generic fail
            return Err(SteamUserError::SteamError("Failed to register API key".into()));
        }

        Err(SteamUserError::SteamError("Failed to retrieve or register API key".into()))
    }

    /// Revokes the current Steam Web API key for the authenticated user.
    #[steam_endpoint(POST, host = Community, path = "/dev/revokekey", kind = Write)]
    pub async fn revoke_web_api_key(&self) -> Result<(), SteamUserError> {
        let response = self.post_path("/dev/revokekey").form(&[("Revoke", "Revoke My Steam Web API Key")]).send().await?;
        self.check_response(&response)?;

        // JS implementation parses the result to confirm revocation (should mean key is
        // null) We can just check status or parse. JS returns the parse result.
        // If we want to return success/fail, we can parse it.
        let html = response.text().await?;
        if parse_web_api_key(&html).is_none() {
            Ok(())
        } else {
            Err(SteamUserError::SteamError("Failed to revoke API key".into()))
        }
    }

    /// Resolves a vanity URL (e.g. "gabelogannewell") to a SteamID using the
    /// Web API.
    ///
    /// # Arguments
    ///
    /// * `api_key` - A valid Steam Web API key.
    /// * `vanity_url_name` - The vanity name to resolve.
    #[steam_endpoint(GET, host = Api, path = "/ISteamUser/ResolveVanityURL/v0001/", kind = Read)]
    pub async fn resolve_vanity_url(&self, api_key: &str, vanity_url_name: &str) -> Result<steamid::SteamID, SteamUserError> {
        let params = [("key", api_key), ("vanityurl", vanity_url_name)];

        let response = self.get_path("/ISteamUser/ResolveVanityURL/v0001/").query(&params).send().await?;
        self.check_response(&response)?;

        let text = response.text().await?;
        let json: ResolveVanityUrlResponse = serde_json::from_str(&text).map_err(|e| SteamUserError::Other(format!("Failed to parse ResolveVanityURL response: {}", e)))?;

        if json.response.success == 1 {
            if let Some(steamid_str) = json.response.steamid {
                return steamid_str.parse::<steamid::SteamID>().map_err(|e| SteamUserError::Other(format!("Failed to parse SteamID from response: {}", e)));
            }
        }

        Err(SteamUserError::Other(json.response.message.unwrap_or_else(|| "Failed to resolve vanity URL".to_string())))
    }

    /// Resolves a vanity URL (e.g. "gabelogannewell") and returns a profile
    /// snapshot, by scraping the public profile XML feed at
    /// `https://steamcommunity.com/id/{vanity}/?xml=1`.
    ///
    /// Anonymous; no API key required. Subject to per-IP community-page rate
    /// limits. For an authenticated / quota-managed alternative that returns
    /// only the SteamID, see [`Self::resolve_vanity_url`].
    ///
    /// # Arguments
    ///
    /// * `vanity` - The vanity name to resolve (alphanumerics, `_`, `-`).
    #[steam_endpoint(GET, host = Community, path = "/id/{vanity}/", kind = Read)]
    pub async fn resolve_vanity_url_public(&self, vanity: &str) -> Result<PublicProfileSummary, SteamUserError> {
        if vanity.is_empty() || !vanity.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
            return Err(SteamUserError::InvalidInput(format!("invalid vanity slug: {vanity:?}")));
        }
        let path = format!("/id/{vanity}/?xml=1");
        self.fetch_public_profile_xml(&path, vanity).await
    }

    /// Fetches a profile snapshot for a known SteamID via the public
    /// `/profiles/{id}/?xml=1` feed. Anonymous; no API key required.
    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
    pub async fn fetch_public_profile_by_id(&self, steam_id: SteamID) -> Result<PublicProfileSummary, SteamUserError> {
        let id_str = steam_id.to_string();
        let path = format!("/profiles/{id_str}/?xml=1");
        self.fetch_public_profile_xml(&path, &id_str).await
    }

    // generic helper — relies on caller's #[steam_endpoint] context
    #[tracing::instrument(skip(self), fields(path = %path, identifier = %identifier))]
    async fn fetch_public_profile_xml(&self, path: &str, identifier: &str) -> Result<PublicProfileSummary, SteamUserError> {
        let response = self.get_path(path).send().await?;
        self.check_response(&response)?;
        let body = response.text().await?;
        parse_public_profile_xml(&body).ok_or_else(|| SteamUserError::Other(format!("profile not found: {identifier}")))
    }
}

/// Extracts the inner text of `<tag>...</tag>`, stripping `<![CDATA[...]]>`
/// wrappers if present. Returns `None` if the tag is absent.
fn extract_tag<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");
    let start = body.find(&open)? + open.len();
    let end = body[start..].find(&close)?;
    let inner = body[start..start + end].trim();
    inner.strip_prefix("<![CDATA[").and_then(|s| s.strip_suffix("]]>")).map(str::trim).or(Some(inner))
}

/// Same as [`extract_tag`] but returns `None` for empty values, so optional
/// fields don't surface as `Some("")`.
fn extract_tag_opt<'a>(body: &'a str, tag: &str) -> Option<&'a str> {
    extract_tag(body, tag).filter(|s| !s.is_empty())
}

fn parse_public_profile_xml(body: &str) -> Option<PublicProfileSummary> {
    // <steamID64> presence is the success discriminator: error responses
    // return <response><error>...</error></response> with no profile tags.
    let steam_id = extract_tag(body, "steamID64")?.parse::<SteamID>().ok()?;

    // Steam emits both <privacyState> ("public"/"friendsonly"/"private") and
    // <visibilityState> (1/2/3). They mean the same thing; trust the integer.
    let privacy_state = extract_tag(body, "visibilityState").and_then(|s| s.parse::<i32>().ok()).map_or(PrivacyState::default(), PrivacyState::from);

    Some(PublicProfileSummary {
        steam_id,
        persona_name: extract_tag(body, "steamID").unwrap_or("").to_owned(),
        online_state: OnlineState::from_xml(extract_tag(body, "onlineState").unwrap_or("")),
        state_message: extract_tag(body, "stateMessage").unwrap_or("").to_owned(),
        privacy_state,
        avatar_icon: extract_tag(body, "avatarIcon").unwrap_or("").to_owned(),
        avatar_medium: extract_tag(body, "avatarMedium").unwrap_or("").to_owned(),
        avatar_full: extract_tag(body, "avatarFull").unwrap_or("").to_owned(),
        vac_banned: extract_tag(body, "vacBanned").is_some_and(|s| s == "1"),
        trade_ban_state: TradeBanState::from_xml(extract_tag(body, "tradeBanState").unwrap_or("None")),
        is_limited_account: extract_tag(body, "isLimitedAccount").is_some_and(|s| s == "1"),
        custom_url: extract_tag_opt(body, "customURL").map(str::to_owned),
        // Client pins l=english, so this parses as the U.S. long form. Steam
        // reports day granularity; promote to midnight UTC so the value is
        // unambiguous downstream.
        member_since: extract_tag_opt(body, "memberSince").and_then(|s| chrono::NaiveDate::parse_from_str(s, "%B %d, %Y").ok()).and_then(|d| d.and_hms_opt(0, 0, 0)).map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc)),
        headline: extract_tag_opt(body, "headline").map(str::to_owned),
        location: extract_tag_opt(body, "location").map(str::to_owned),
        real_name: extract_tag_opt(body, "realname").map(str::to_owned),
        summary: extract_tag_opt(body, "summary").map(str::to_owned),
        hours_played_2wk: extract_tag_opt(body, "hoursPlayed2Wk").and_then(|s| s.parse().ok()),
    })
}

/// Parses the Steam Web API Key from the developer page HTML.
fn parse_web_api_key(html: &str) -> Option<String> {
    if html.contains("Revoke My Steam Web API Key") {
        let fragment = Html::parse_fragment(html);
        let p_selector = Selector::parse("#bodyContents_ex > p").ok()?;

        for element in fragment.select(&p_selector) {
            let text = element.text().collect::<String>();
            if text.contains("Key:") {
                let parts: Vec<&str> = text.split("Key:").collect();
                if parts.len() > 1 {
                    return Some(parts[1].trim().to_string());
                }
            }
        }
    }
    None
}

#[derive(serde::Deserialize)]
struct ResolveVanityUrlResponse {
    response: ResolveVanityUrlResponseInner,
}

#[derive(serde::Deserialize)]
struct ResolveVanityUrlResponseInner {
    steamid: Option<String>,
    success: i32,
    message: Option<String>,
}