steam-user 0.1.0

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

use std::sync::OnceLock;

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

static SEL_COMMENT_THREAD: OnceLock<Selector> = OnceLock::new();
fn sel_comment_thread() -> &'static Selector {
    SEL_COMMENT_THREAD.get_or_init(|| Selector::parse(".commentthread_comment").expect("valid CSS selector"))
}

static SEL_COMMENT_TEXT: OnceLock<Selector> = OnceLock::new();
fn sel_comment_text() -> &'static Selector {
    SEL_COMMENT_TEXT.get_or_init(|| Selector::parse(".commentthread_comment_text").expect("valid CSS selector"))
}

static SEL_COMMENT_TIMESTAMP: OnceLock<Selector> = OnceLock::new();
fn sel_comment_timestamp() -> &'static Selector {
    SEL_COMMENT_TIMESTAMP.get_or_init(|| Selector::parse(".commentthread_comment_timestamp").expect("valid CSS selector"))
}

static SEL_COMMENT_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_comment_avatar_img() -> &'static Selector {
    SEL_COMMENT_AVATAR_IMG.get_or_init(|| Selector::parse(".commentthread_comment_avatar a img").expect("valid CSS selector"))
}

static SEL_COMMENT_AUTHOR: OnceLock<Selector> = OnceLock::new();
fn sel_comment_author() -> &'static Selector {
    SEL_COMMENT_AUTHOR.get_or_init(|| Selector::parse(".commentthread_comment_author a").expect("valid CSS selector"))
}

use crate::{
    client::SteamUser,
    endpoint::steam_endpoint,
    error::SteamUserError,
    types::{CommentAuthor, UserComment},
    utils::avatar::{extract_custom_url, get_avatar_hash_from_url},
};

impl SteamUser {
    /// Retrieves all comments from the current user's profile.
    ///
    /// This is a convenience method that calls [`Self::get_user_comments`] with
    /// the authenticated user's Steam ID.
    ///
    /// # Returns
    ///
    /// Returns a `Vec<UserComment>` containing all comments on the user's
    /// profile.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let comments = user.get_my_comments().await?;
    /// println!("You have {} comments on your profile.", comments.len());
    /// # Ok(())
    /// # }
    /// ```
    // delegates to `get_user_comments` — no #[steam_endpoint]
    #[tracing::instrument(skip(self))]
    pub async fn get_my_comments(&self) -> Result<Vec<UserComment>, SteamUserError> {
        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
        self.get_user_comments(steam_id).await
    }

    /// Retrieves all comments from the specified Steam profile.
    ///
    /// This method handles pagination automatically, fetching all available
    /// comments by repeatedly querying the Steam Community servers.
    ///
    /// # Arguments
    ///
    /// * `steam_id` - The [`SteamID`] of the user whose profile comments you
    ///   want to retrieve.
    ///
    /// # Returns
    ///
    /// Returns a `Vec<UserComment>` containing all comments found on the
    /// profile.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # use steamid::SteamID;
    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let target_sid = SteamID::from(76561197960287930);
    /// let comments = user.get_user_comments(target_sid).await?;
    /// for comment in comments {
    ///     println!("{}: {}", comment.author.name, comment.content);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(GET, host = Community, path = "/comment/Profile/render/{steam_id}/-1/", kind = Read)]
    pub async fn get_user_comments(&self, steam_id: SteamID) -> Result<Vec<UserComment>, SteamUserError> {
        let path = format!("/comment/Profile/render/{}/-1/", steam_id.steam_id64());

        let response: serde_json::Value = self.get_path(&path).send().await?.json().await?;

        let comments_html = response.get("comments_html").and_then(|v| v.as_str()).unwrap_or("");
        let mut comments = parse_comments(comments_html);

        let total_count = response.get("total_count").and_then(|v| v.as_u64()).unwrap_or(0);
        let pagesize = response.get("pagesize").and_then(|v| v.as_u64()).unwrap_or(50);

        if (comments.len() as u64) < total_count {
            let mut start = comments.len() as u64;
            while start < total_count {
                let form = [("start", start.to_string()), ("totalcount", total_count.to_string()), ("count", pagesize.to_string()), ("feature2", "-1".to_string())];

                let page_response: serde_json::Value = self.post_path(&path).form(&form).send().await?.json().await?;
                if let Some(html) = page_response.get("comments_html").and_then(|v| v.as_str()) {
                    comments.extend(parse_comments(html));
                }
                start += pagesize;
            }
        }

        Ok(comments)
    }

    /// Posts a comment on the specified Steam profile.
    ///
    /// Sends a POST request to the Steam Community server to add a new comment.
    ///
    /// # Arguments
    ///
    /// * `steam_id` - The [`SteamID`] of the profile to post the comment on.
    /// * `message` - The text content of the comment.
    ///
    /// # Returns
    ///
    /// Returns `Ok(Some(UserComment))` containing the newly posted comment if
    /// successful, or `Ok(None)` if the comment was posted but could not be
    /// parsed from the response.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # use steamid::SteamID;
    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let target_sid = SteamID::from(76561197960287930);
    /// let comment = user
    ///     .post_comment(target_sid, "Hello from rust-steam!")
    ///     .await?;
    /// if let Some(c) = comment {
    ///     println!("Posted comment with ID: {}", c.id);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(POST, host = Community, path = "/comment/Profile/post/{steam_id}/-1/", kind = Write)]
    pub async fn post_comment(&self, steam_id: SteamID, message: &str) -> Result<Option<UserComment>, SteamUserError> {
        let form = [("comment", message), ("count", "6"), ("feature2", "-1")];

        let response: serde_json::Value = self.post_path(format!("/comment/Profile/post/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;

        if let Some(html) = response.get("comments_html").and_then(|v| v.as_str()) {
            let comments = parse_comments(html);
            let last_post = response.get("timelastpost").and_then(|v| v.as_u64());

            if let Some(ts) = last_post {
                return Ok(comments.into_iter().find(|c| c.timestamp == ts));
            }
            return Ok(comments.first().cloned());
        }

        Ok(None)
    }

    /// Deletes a specific comment from the specified Steam profile.
    ///
    /// # Arguments
    ///
    /// * `steam_id` - The [`SteamID`] of the profile where the comment is
    ///   located.
    /// * `gidcomment` - The unique ID of the comment to delete (e.g.,
    ///   "1234567890").
    ///
    /// # Returns
    ///
    /// Returns `Ok(())` if the comment was successfully deleted.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # use steam_user::client::SteamUser;
    /// # use steamid::SteamID;
    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
    /// let target_sid = SteamID::from(76561197960287930);
    /// user.delete_comment(target_sid, "1234567890").await?;
    /// # Ok(())
    /// # }
    /// ```
    #[steam_endpoint(POST, host = Community, path = "/comment/Profile/delete/{steam_id}/-1/", kind = Write)]
    pub async fn delete_comment(&self, steam_id: SteamID, gidcomment: &str) -> Result<(), SteamUserError> {
        let form = [("gidcomment", gidcomment), ("start", "0"), ("count", "6"), ("feature2", "-1")];

        let response: serde_json::Value = self.post_path(format!("/comment/Profile/delete/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;

        let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) || response.get("success").and_then(|v| v.as_i64()).map(|n| n == 1).unwrap_or(false);

        if success {
            Ok(())
        } else {
            Err(SteamUserError::SteamError("Failed to delete comment".into()))
        }
    }
}

/// Parses Steam comment HTML into an array of structured comment objects.
fn parse_comments(html: &str) -> Vec<UserComment> {
    let document = Html::parse_document(&format!("<div>{}</div>", html));
    let mut comments = Vec::new();

    for element in document.select(sel_comment_thread()) {
        let id = element.value().attr("id").unwrap_or("").replace("comment_", "");
        let content = element.select(sel_comment_text()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();

        let timestamp = element.select(sel_comment_timestamp()).next().and_then(|el| el.value().attr("data-timestamp")).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);

        let avatar_el = element.select(sel_comment_avatar_img()).next();
        let avatar = avatar_el.and_then(|el| el.value().attr("src")).unwrap_or("").to_string();
        let avatar_hash = get_avatar_hash_from_url(&avatar).unwrap_or_default();

        let author_el = element.select(sel_comment_author()).next();
        let profile_url = author_el.and_then(|el| el.value().attr("href")).map(|s| s.to_string());
        let name = author_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
        let miniprofile = author_el.and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u64>().ok());

        // data-miniprofile is a 32-bit Steam account ID. Drop the SteamID if the
        // raw value doesn't fit in u32 instead of silently truncating.
        let steam_id = miniprofile.and_then(|mp| u32::try_from(mp).ok()).map(SteamID::from_individual_account_id);
        let custom_url = profile_url.as_deref().and_then(extract_custom_url);

        comments.push(UserComment {
            id,
            content,
            timestamp,
            author: CommentAuthor { steam_id, name, avatar, avatar_hash, profile_url, custom_url, miniprofile },
        });
    }

    comments
}