Skip to main content

steam_user/services/
comments.rs

1//! Comment management services.
2
3use std::sync::OnceLock;
4
5use scraper::{Html, Selector};
6use steamid::SteamID;
7
8static SEL_COMMENT_THREAD: OnceLock<Selector> = OnceLock::new();
9fn sel_comment_thread() -> &'static Selector {
10    SEL_COMMENT_THREAD.get_or_init(|| Selector::parse(".commentthread_comment").expect("valid CSS selector"))
11}
12
13static SEL_COMMENT_TEXT: OnceLock<Selector> = OnceLock::new();
14fn sel_comment_text() -> &'static Selector {
15    SEL_COMMENT_TEXT.get_or_init(|| Selector::parse(".commentthread_comment_text").expect("valid CSS selector"))
16}
17
18static SEL_COMMENT_TIMESTAMP: OnceLock<Selector> = OnceLock::new();
19fn sel_comment_timestamp() -> &'static Selector {
20    SEL_COMMENT_TIMESTAMP.get_or_init(|| Selector::parse(".commentthread_comment_timestamp").expect("valid CSS selector"))
21}
22
23static SEL_COMMENT_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
24fn sel_comment_avatar_img() -> &'static Selector {
25    SEL_COMMENT_AVATAR_IMG.get_or_init(|| Selector::parse(".commentthread_comment_avatar a img").expect("valid CSS selector"))
26}
27
28static SEL_COMMENT_AUTHOR: OnceLock<Selector> = OnceLock::new();
29fn sel_comment_author() -> &'static Selector {
30    SEL_COMMENT_AUTHOR.get_or_init(|| Selector::parse(".commentthread_comment_author a").expect("valid CSS selector"))
31}
32
33use crate::{
34    client::SteamUser,
35    endpoint::steam_endpoint,
36    error::SteamUserError,
37    types::{CommentAuthor, UserComment},
38    utils::avatar::{extract_custom_url, get_avatar_hash_from_url},
39};
40
41impl SteamUser {
42    /// Retrieves all comments from the current user's profile.
43    ///
44    /// This is a convenience method that calls [`Self::get_user_comments`] with
45    /// the authenticated user's Steam ID.
46    ///
47    /// # Returns
48    ///
49    /// Returns a `Vec<UserComment>` containing all comments on the user's
50    /// profile.
51    ///
52    /// # Example
53    ///
54    /// ```rust,no_run
55    /// # use steam_user::client::SteamUser;
56    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
57    /// let comments = user.get_my_comments().await?;
58    /// println!("You have {} comments on your profile.", comments.len());
59    /// # Ok(())
60    /// # }
61    /// ```
62    // delegates to `get_user_comments` — no #[steam_endpoint]
63    #[tracing::instrument(skip(self))]
64    pub async fn get_my_comments(&self) -> Result<Vec<UserComment>, SteamUserError> {
65        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
66        self.get_user_comments(steam_id).await
67    }
68
69    /// Retrieves all comments from the specified Steam profile.
70    ///
71    /// This method handles pagination automatically, fetching all available
72    /// comments by repeatedly querying the Steam Community servers.
73    ///
74    /// # Arguments
75    ///
76    /// * `steam_id` - The [`SteamID`] of the user whose profile comments you
77    ///   want to retrieve.
78    ///
79    /// # Returns
80    ///
81    /// Returns a `Vec<UserComment>` containing all comments found on the
82    /// profile.
83    ///
84    /// # Example
85    ///
86    /// ```rust,no_run
87    /// # use steam_user::client::SteamUser;
88    /// # use steamid::SteamID;
89    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
90    /// let target_sid = SteamID::from(76561197960287930);
91    /// let comments = user.get_user_comments(target_sid).await?;
92    /// for comment in comments {
93    ///     println!("{}: {}", comment.author.name, comment.content);
94    /// }
95    /// # Ok(())
96    /// # }
97    /// ```
98    #[steam_endpoint(GET, host = Community, path = "/comment/Profile/render/{steam_id}/-1/", kind = Read)]
99    pub async fn get_user_comments(&self, steam_id: SteamID) -> Result<Vec<UserComment>, SteamUserError> {
100        let path = format!("/comment/Profile/render/{}/-1/", steam_id.steam_id64());
101
102        let response: serde_json::Value = self.get_path(&path).send().await?.json().await?;
103
104        let comments_html = response.get("comments_html").and_then(|v| v.as_str()).unwrap_or("");
105        let mut comments = parse_comments(comments_html);
106
107        let total_count = response.get("total_count").and_then(|v| v.as_u64()).unwrap_or(0);
108        let pagesize = response.get("pagesize").and_then(|v| v.as_u64()).unwrap_or(50);
109
110        if (comments.len() as u64) < total_count {
111            let mut start = comments.len() as u64;
112            while start < total_count {
113                let form = [("start", start.to_string()), ("totalcount", total_count.to_string()), ("count", pagesize.to_string()), ("feature2", "-1".to_string())];
114
115                let page_response: serde_json::Value = self.post_path(&path).form(&form).send().await?.json().await?;
116                if let Some(html) = page_response.get("comments_html").and_then(|v| v.as_str()) {
117                    comments.extend(parse_comments(html));
118                }
119                start += pagesize;
120            }
121        }
122
123        Ok(comments)
124    }
125
126    /// Posts a comment on the specified Steam profile.
127    ///
128    /// Sends a POST request to the Steam Community server to add a new comment.
129    ///
130    /// # Arguments
131    ///
132    /// * `steam_id` - The [`SteamID`] of the profile to post the comment on.
133    /// * `message` - The text content of the comment.
134    ///
135    /// # Returns
136    ///
137    /// Returns `Ok(Some(UserComment))` containing the newly posted comment if
138    /// successful, or `Ok(None)` if the comment was posted but could not be
139    /// parsed from the response.
140    ///
141    /// # Example
142    ///
143    /// ```rust,no_run
144    /// # use steam_user::client::SteamUser;
145    /// # use steamid::SteamID;
146    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
147    /// let target_sid = SteamID::from(76561197960287930);
148    /// let comment = user
149    ///     .post_comment(target_sid, "Hello from rust-steam!")
150    ///     .await?;
151    /// if let Some(c) = comment {
152    ///     println!("Posted comment with ID: {}", c.id);
153    /// }
154    /// # Ok(())
155    /// # }
156    /// ```
157    #[steam_endpoint(POST, host = Community, path = "/comment/Profile/post/{steam_id}/-1/", kind = Write)]
158    pub async fn post_comment(&self, steam_id: SteamID, message: &str) -> Result<Option<UserComment>, SteamUserError> {
159        let form = [("comment", message), ("count", "6"), ("feature2", "-1")];
160
161        let response: serde_json::Value = self.post_path(format!("/comment/Profile/post/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;
162
163        if let Some(html) = response.get("comments_html").and_then(|v| v.as_str()) {
164            let comments = parse_comments(html);
165            let last_post = response.get("timelastpost").and_then(|v| v.as_u64());
166
167            if let Some(ts) = last_post {
168                return Ok(comments.into_iter().find(|c| c.timestamp == ts));
169            }
170            return Ok(comments.first().cloned());
171        }
172
173        Ok(None)
174    }
175
176    /// Deletes a specific comment from the specified Steam profile.
177    ///
178    /// # Arguments
179    ///
180    /// * `steam_id` - The [`SteamID`] of the profile where the comment is
181    ///   located.
182    /// * `gidcomment` - The unique ID of the comment to delete (e.g.,
183    ///   "1234567890").
184    ///
185    /// # Returns
186    ///
187    /// Returns `Ok(())` if the comment was successfully deleted.
188    ///
189    /// # Example
190    ///
191    /// ```rust,no_run
192    /// # use steam_user::client::SteamUser;
193    /// # use steamid::SteamID;
194    /// # async fn example(mut user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
195    /// let target_sid = SteamID::from(76561197960287930);
196    /// user.delete_comment(target_sid, "1234567890").await?;
197    /// # Ok(())
198    /// # }
199    /// ```
200    #[steam_endpoint(POST, host = Community, path = "/comment/Profile/delete/{steam_id}/-1/", kind = Write)]
201    pub async fn delete_comment(&self, steam_id: SteamID, gidcomment: &str) -> Result<(), SteamUserError> {
202        let form = [("gidcomment", gidcomment), ("start", "0"), ("count", "6"), ("feature2", "-1")];
203
204        let response: serde_json::Value = self.post_path(format!("/comment/Profile/delete/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;
205
206        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);
207
208        if success {
209            Ok(())
210        } else {
211            Err(SteamUserError::SteamError("Failed to delete comment".into()))
212        }
213    }
214}
215
216/// Parses Steam comment HTML into an array of structured comment objects.
217fn parse_comments(html: &str) -> Vec<UserComment> {
218    let document = Html::parse_document(&format!("<div>{}</div>", html));
219    let mut comments = Vec::new();
220
221    for element in document.select(sel_comment_thread()) {
222        let id = element.value().attr("id").unwrap_or("").replace("comment_", "");
223        let content = element.select(sel_comment_text()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
224
225        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);
226
227        let avatar_el = element.select(sel_comment_avatar_img()).next();
228        let avatar = avatar_el.and_then(|el| el.value().attr("src")).unwrap_or("").to_string();
229        let avatar_hash = get_avatar_hash_from_url(&avatar).unwrap_or_default();
230
231        let author_el = element.select(sel_comment_author()).next();
232        let profile_url = author_el.and_then(|el| el.value().attr("href")).map(|s| s.to_string());
233        let name = author_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
234        let miniprofile = author_el.and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u64>().ok());
235
236        // data-miniprofile is a 32-bit Steam account ID. Drop the SteamID if the
237        // raw value doesn't fit in u32 instead of silently truncating.
238        let steam_id = miniprofile.and_then(|mp| u32::try_from(mp).ok()).map(SteamID::from_individual_account_id);
239        let custom_url = profile_url.as_deref().and_then(extract_custom_url);
240
241        comments.push(UserComment {
242            id,
243            content,
244            timestamp,
245            author: CommentAuthor { steam_id, name, avatar, avatar_hash, profile_url, custom_url, miniprofile },
246        });
247    }
248
249    comments
250}