Skip to main content

steam_user/services/
confirmations.rs

1//! Mobile confirmation services.
2
3use steam_totp::{generate_confirmation_key, generate_device_id, Secret};
4
5use crate::{client::SteamUser, endpoint::steam_endpoint, error::SteamUserError, types::Confirmation};
6
7impl SteamUser {
8    /// Retrieves a list of outstanding mobile confirmations.
9    ///
10    /// Fetches the confirmation list from `https://steamcommunity.com/mobileconf/getlist`.
11    ///
12    /// # Arguments
13    ///
14    /// * `identity_secret` - The identity secret for the account (base64
15    ///   encoded).
16    /// * `tag` - Optional tag for the request (defaults to "conf").
17    ///
18    /// # Returns
19    ///
20    /// Returns a `Vec<Confirmation>` containing all pending confirmations.
21    ///
22    /// # Errors
23    ///
24    /// - Returns [`SteamUserError::SessionExpired`] if the session is no longer
25    ///   valid.
26    /// - Returns [`SteamUserError::SteamError`] if the request fails on the
27    ///   Steam side.
28    ///
29    /// # Example
30    ///
31    /// ```rust,no_run
32    /// # use steam_user::client::SteamUser;
33    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
34    /// let identity_secret = "your_base64_identity_secret";
35    /// let confs = user.get_confirmations(identity_secret, None).await?;
36    /// for conf in confs {
37    ///     println!("Confirmation: {} (Title: {})", conf.id, conf.title);
38    /// }
39    /// # Ok(())
40    /// # }
41    /// ```
42    #[steam_endpoint(GET, host = Community, path = "/mobileconf/getlist", kind = Auth)]
43    pub async fn get_confirmations(&self, identity_secret: &str, tag: Option<&str>) -> Result<Vec<Confirmation>, SteamUserError> {
44        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
45        let device_id = generate_device_id(steam_id, None);
46
47        let time = i64::try_from(
48            std::time::SystemTime::now()
49                .duration_since(std::time::UNIX_EPOCH)?
50                .as_secs(),
51        )
52        .unwrap_or(0);
53
54        let tag = tag.unwrap_or("conf");
55        let secret = Secret::from_string(identity_secret)?;
56        let key = generate_confirmation_key(&secret, time, tag)?;
57
58        let time_str = time.to_string();
59        let steam_id_str = steam_id.steam_id64().to_string();
60
61        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)];
62
63        // "conf" is used for getlist in node library (legacy tag "list" also works but
64        // "conf" is safer)
65        let response: serde_json::Value = self.get_path("/mobileconf/getlist").query(&params).send().await?.json().await?;
66
67        if !response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
68            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();
69
70            if response.get("needauth").and_then(|v| v.as_bool()).unwrap_or(false) {
71                return Err(SteamUserError::SessionExpired);
72            }
73
74            return Err(SteamUserError::SteamError(message));
75        }
76
77        let confs = response
78            .get("conf")
79            .and_then(|v| v.as_array())
80            .map(|arr| {
81                arr.iter()
82                    .filter_map(|v| match Confirmation::from_api(v) {
83                        Ok(c) => Some(c),
84                        Err(e) => {
85                            tracing::warn!(error = %e, "skipping malformed confirmation entry");
86                            None
87                        }
88                    })
89                    .collect()
90            })
91            .unwrap_or_default();
92
93        Ok(confs)
94    }
95
96    /// Responds to a single confirmation by either accepting or canceling it.
97    ///
98    /// # Arguments
99    ///
100    /// * `confirmation` - The [`Confirmation`] to respond to.
101    /// * `identity_secret` - The identity secret for the account (base64
102    ///   encoded).
103    /// * `accept` - `true` to accept, `false` to cancel/deny.
104    ///
105    /// # Example
106    ///
107    /// ```rust,no_run
108    /// # use steam_user::client::SteamUser;
109    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
110    /// # let conf = todo!();
111    /// # let identity_secret = "secret";
112    /// user.respond_to_confirmation(&conf, identity_secret, true)
113    ///     .await?;
114    /// # Ok(())
115    /// # }
116    /// ```
117    // delegates to `respond_to_confirmations` — no #[steam_endpoint]
118    #[tracing::instrument(skip(self, identity_secret), fields(accept))]
119    pub async fn respond_to_confirmation(&self, confirmation: &Confirmation, identity_secret: &str, accept: bool) -> Result<(), SteamUserError> {
120        self.respond_to_confirmations(std::slice::from_ref(confirmation), identity_secret, accept).await
121    }
122
123    /// Responds to multiple confirmations at once.
124    ///
125    /// Sends a POST request to `https://steamcommunity.com/mobileconf/multiajaxop`.
126    ///
127    /// # Arguments
128    ///
129    /// * `confirmations` - A slice of [`Confirmation`] structs to respond to.
130    /// * `identity_secret` - The identity secret for the account (base64
131    ///   encoded).
132    /// * `accept` - `true` to accept all, `false` to cancel/deny all.
133    ///
134    /// # Example
135    ///
136    /// ```rust,no_run
137    /// # use steam_user::client::SteamUser;
138    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
139    /// # let confs = vec![];
140    /// # let identity_secret = "secret";
141    /// user.respond_to_confirmations(&confs, identity_secret, true)
142    ///     .await?;
143    /// # Ok(())
144    /// # }
145    /// ```
146    #[steam_endpoint(POST, host = Community, path = "/mobileconf/multiajaxop", kind = Auth)]
147    pub async fn respond_to_confirmations(&self, confirmations: &[Confirmation], identity_secret: &str, accept: bool) -> Result<(), SteamUserError> {
148        if confirmations.is_empty() {
149            return Ok(());
150        }
151
152        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
153        let device_id = generate_device_id(steam_id, None);
154
155        let time = i64::try_from(
156            std::time::SystemTime::now()
157                .duration_since(std::time::UNIX_EPOCH)?
158                .as_secs(),
159        )
160        .unwrap_or(0);
161
162        let tag = if accept { "allow" } else { "cancel" };
163        let secret = Secret::from_string(identity_secret)?;
164        let key = generate_confirmation_key(&secret, time, tag)?;
165
166        let op = if accept { "allow" } else { "cancel" };
167
168        let time_str = time.to_string();
169        let steam_id_str = steam_id.steam_id64().to_string();
170
171        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)];
172
173        let mut form_params = Vec::new();
174        for (k, v) in params.iter() {
175            form_params.push((*k, *v));
176        }
177
178        // Add cid and ck arrays
179        for conf in confirmations {
180            form_params.push(("cid[]", conf.id.as_str()));
181            form_params.push(("ck[]", conf.key.as_str()));
182        }
183
184        let response: serde_json::Value = self.post_path("/mobileconf/multiajaxop").form(&form_params).send().await?.json().await?;
185
186        if response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
187            Ok(())
188        } else {
189            let message = response.get("message").and_then(|v| v.as_str()).unwrap_or("Could not act on confirmation").to_string();
190            Err(SteamUserError::SteamError(message))
191        }
192    }
193
194    /// Retrieves the trade offer ID or market listing ID associated with a
195    /// confirmation.
196    ///
197    /// Scrapes the confirmation details page at `https://steamcommunity.com/mobileconf/detailspage/<id>`.
198    ///
199    /// # Arguments
200    ///
201    /// * `confirmation` - The [`Confirmation`] to get details for.
202    /// * `identity_secret` - The identity secret for the account (base64
203    ///   encoded).
204    ///
205    /// # Returns
206    ///
207    /// Returns `Ok(Some(String))` containing the object ID if found.
208    #[steam_endpoint(GET, host = Community, path = "/mobileconf/detailspage/{confirmation_id}", kind = Auth)]
209    pub async fn get_confirmation_offer_id(&self, confirmation: &Confirmation, identity_secret: &str) -> Result<Option<String>, SteamUserError> {
210        if let Some(offer_id) = confirmation.offer_id() {
211            return Ok(Some(offer_id.to_string()));
212        }
213
214        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
215        let device_id = generate_device_id(steam_id, None);
216
217        let time = i64::try_from(
218            std::time::SystemTime::now()
219                .duration_since(std::time::UNIX_EPOCH)?
220                .as_secs(),
221        )
222        .unwrap_or(0);
223
224        let secret = Secret::from_string(identity_secret)?;
225        let key = generate_confirmation_key(&secret, time, "details")?;
226
227        let time_str = time.to_string();
228        let steam_id_str = steam_id.steam_id64().to_string();
229
230        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")];
231
232        let response = self.get_path(format!("/mobileconf/detailspage/{}", confirmation.id)).query(&params).send().await?.text().await?;
233
234        // Parse HTML to find tradeoffer id
235        use scraper::{Html, Selector};
236        let document = Html::parse_document(&response);
237        let selector = Selector::parse(".tradeoffer").map_err(|e| SteamUserError::MalformedResponse(format!("Invalid selector: {e}")))?;
238
239        if let Some(element) = document.select(&selector).next() {
240            if let Some(id_attr) = element.value().attr("id") {
241                // Format is usually tradeofferid_12345
242                if let Some(parts) = id_attr.split('_').nth(1) {
243                    return Ok(Some(parts.to_string()));
244                }
245            }
246        }
247
248        Ok(None)
249    }
250
251    /// Performs an action (accept or deny) for a specific confirmation object
252    /// ID.
253    ///
254    /// This method first fetches the confirmation list to find the one matching
255    /// the `object_id`.
256    ///
257    /// # Arguments
258    ///
259    /// * `identity_secret` - The identity secret for the account (base64
260    ///   encoded).
261    /// * `object_id` - The unique identifier for the confirmation object (trade
262    ///   offer ID or market listing ID).
263    /// * `accept` - `true` to accept, `false` to deny.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`SteamUserError::ConfirmationNotFound`] if no confirmation
268    /// matches the ID.
269    // delegates to `get_confirmations` + `respond_to_confirmation` — no #[steam_endpoint]
270    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id, accept = accept))]
271    pub async fn perform_confirmation_action(&self, identity_secret: &str, object_id: u64, accept: bool) -> Result<(), SteamUserError> {
272        let tag = if accept { "list" } else { "conf" };
273        let confirmations = self.get_confirmations(identity_secret, Some(tag)).await?;
274
275        let object_id_str = object_id.to_string();
276        let confirmation = confirmations.iter().find(|c| c.creator == object_id_str).ok_or(SteamUserError::ConfirmationNotFound(object_id))?;
277
278        self.respond_to_confirmation(confirmation, identity_secret, accept).await
279    }
280
281    /// Accepts a confirmation for a specific object (trade offer ID or market
282    /// listing ID).
283    ///
284    /// Convenience wrapper around [`perform_confirmation_action`].
285    // delegates to `perform_confirmation_action` — no #[steam_endpoint]
286    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id))]
287    pub async fn accept_confirmation_for_object(&self, identity_secret: &str, object_id: u64) -> Result<(), SteamUserError> {
288        self.perform_confirmation_action(identity_secret, object_id, true).await
289    }
290
291    /// Denies a confirmation for a specific object (trade offer ID or market
292    /// listing ID).
293    ///
294    /// Convenience wrapper around [`perform_confirmation_action`].
295    // delegates to `perform_confirmation_action` — no #[steam_endpoint]
296    #[tracing::instrument(skip(self, identity_secret), fields(object_id = object_id))]
297    pub async fn deny_confirmation_for_object(&self, identity_secret: &str, object_id: u64) -> Result<(), SteamUserError> {
298        self.perform_confirmation_action(identity_secret, object_id, false).await
299    }
300}