Skip to main content

steam_client/services/
gifts.rs

1//! Steam Gift redemption features.
2//!
3//! This module provides functionality for redeeming gifts from the Steam
4//! inventory:
5//! - Get gift details (validate before redeeming)
6//! - Redeem a gift to your library
7
8use serde::Deserialize;
9
10use crate::{error::SteamError, SteamClient};
11
12/// Gift details from validation.
13#[derive(Debug, Clone)]
14pub struct GiftDetails {
15    /// Name of the gift/game
16    pub gift_name: String,
17    /// Package ID
18    pub package_id: u32,
19    /// Whether the gift is already owned
20    pub owned: bool,
21}
22
23/// Internal response structure for gift validation.
24#[derive(Debug, Deserialize)]
25struct GiftValidateResponse {
26    success: Option<i32>,
27    gift_name: Option<String>,
28    #[serde(deserialize_with = "deserialize_id")]
29    packageid: Option<String>,
30    owned: Option<bool>,
31    message: Option<String>,
32}
33
34/// Helper to deserialize fields that could be strings or numbers.
35fn deserialize_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
36where
37    D: serde::Deserializer<'de>,
38{
39    use serde::Deserialize;
40    let v: serde_json::Value = Deserialize::deserialize(deserializer)?;
41    match v {
42        serde_json::Value::String(s) => Ok(Some(s)),
43        serde_json::Value::Number(n) => Ok(Some(n.to_string())),
44        serde_json::Value::Null => Ok(None),
45        _ => Err(serde::de::Error::custom("expected string or number")),
46    }
47}
48
49/// Internal response structure for gift redemption.
50#[derive(Debug, Deserialize)]
51struct GiftRedeemResponse {
52    success: Option<i32>,
53    message: Option<String>,
54}
55
56/// Extract session ID from cookies string.
57///
58/// The sessionid is typically stored as `sessionid=<value>` in the cookie
59/// string.
60fn extract_session_id(cookies: &str) -> Option<String> {
61    for part in cookies.split(';') {
62        let part = part.trim();
63        if let Some(value) = part.strip_prefix("sessionid=") {
64            return Some(value.to_string());
65        }
66    }
67    None
68}
69
70impl SteamClient {
71    /// Get details about a gift in your inventory.
72    ///
73    /// This validates the gift and returns information about it before
74    /// redemption.
75    ///
76    /// # Arguments
77    ///
78    /// * `gift_id` - The gift ID from the inventory
79    /// * `cookies` - Web session cookies (e.g.,
80    ///   "sessionid=xxx;steamLoginSecure=yyy")
81    ///
82    /// # Example
83    ///
84    /// ```rust,ignore
85    /// let details = client.get_gift_details("1234567890", &cookies).await?;
86    /// tracing::info!("Gift: {} (Package {})", details.gift_name, details.package_id);
87    /// if details.owned {
88    ///     tracing::info!("Warning: You already own this game!");
89    /// }
90    /// ```
91    pub async fn get_gift_details(&self, gift_id: &str, cookies: &str) -> Result<GiftDetails, SteamError> {
92        let session_id = extract_session_id(cookies).ok_or_else(|| SteamError::Other("No sessionid found in cookies".to_string()))?;
93
94        let url = format!("https://steamcommunity.com/gifts/{}/validateunpack", gift_id);
95
96        // Create a custom client with cookies and consistent User-Agent
97        let client = reqwest::Client::builder()
98            .user_agent("Valve/Steam HTTP Client 1.0")
99            .default_headers({
100                let mut headers = reqwest::header::HeaderMap::new();
101                headers.insert(reqwest::header::COOKIE, cookies.parse().map_err(|_| SteamError::Other("Invalid cookie header".to_string()))?);
102                headers
103            })
104            .build()
105            .map_err(|e| SteamError::Other(format!("Failed to build HTTP client: {}", e)))?;
106
107        let response = client.post(&url).form(&[("sessionid", &session_id)]).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
108
109        if !response.status().is_success() {
110            return Err(SteamError::Other(format!("HTTP error: {}", response.status())));
111        }
112
113        let body: GiftValidateResponse = response.json().await.map_err(|e| SteamError::ProtocolError(format!("Failed to parse response: {}", e)))?;
114
115        // Check success (1 = OK in Steam's EResult)
116        if body.success != Some(1) {
117            return Err(SteamError::Other(body.message.unwrap_or_else(|| "Unknown error validating gift".to_string())));
118        }
119
120        let gift_name = body.gift_name.ok_or_else(|| SteamError::ProtocolError("Missing gift_name in response".to_string()))?;
121        let package_id: u32 = body.packageid.ok_or_else(|| SteamError::ProtocolError("Missing packageid in response".to_string()))?.parse().map_err(|_| SteamError::ProtocolError("Invalid packageid".to_string()))?;
122
123        Ok(GiftDetails { gift_name, package_id, owned: body.owned.unwrap_or(false) })
124    }
125
126    /// Redeem a gift from your inventory to your library.
127    ///
128    /// This unpacks the gift and adds the game to your Steam library.
129    ///
130    /// # Arguments
131    ///
132    /// * `gift_id` - The gift ID from the inventory
133    /// * `cookies` - Web session cookies (e.g.,
134    ///   "sessionid=xxx;steamLoginSecure=yyy")
135    ///
136    /// # Example
137    ///
138    /// ```rust,ignore
139    /// // First validate the gift
140    /// let details = client.get_gift_details("1234567890", &cookies).await?;
141    /// if !details.owned {
142    ///     client.redeem_gift("1234567890", &cookies).await?;
143    ///     tracing::info!("Redeemed: {}", details.gift_name);
144    /// }
145    /// ```
146    pub async fn redeem_gift(&self, gift_id: &str, cookies: &str) -> Result<(), SteamError> {
147        let session_id = extract_session_id(cookies).ok_or_else(|| SteamError::Other("No sessionid found in cookies".to_string()))?;
148
149        let url = format!("https://steamcommunity.com/gifts/{}/unpack", gift_id);
150
151        // Create a custom client with cookies and consistent User-Agent
152        let client = reqwest::Client::builder()
153            .user_agent("Valve/Steam HTTP Client 1.0")
154            .default_headers({
155                let mut headers = reqwest::header::HeaderMap::new();
156                headers.insert(reqwest::header::COOKIE, cookies.parse().map_err(|_| SteamError::Other("Invalid cookie header".to_string()))?);
157                headers
158            })
159            .build()
160            .map_err(|e| SteamError::Other(format!("Failed to build HTTP client: {}", e)))?;
161
162        let response = client.post(&url).form(&[("sessionid", &session_id)]).send().await.map_err(|e| SteamError::NetworkError(std::io::Error::other(e)))?;
163
164        if !response.status().is_success() {
165            return Err(SteamError::Other(format!("HTTP error: {}", response.status())));
166        }
167
168        let body: GiftRedeemResponse = response.json().await.map_err(|e| SteamError::ProtocolError(format!("Failed to parse response: {}", e)))?;
169
170        // Check success (1 = OK in Steam's EResult)
171        if body.success != Some(1) {
172            return Err(SteamError::Other(body.message.unwrap_or_else(|| "Unknown error redeeming gift".to_string())));
173        }
174
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_extract_session_id() {
185        let cookies = "sessionid=abc123; steamLoginSecure=xyz789";
186        assert_eq!(extract_session_id(cookies), Some("abc123".to_string()));
187
188        let cookies_no_session = "steamLoginSecure=xyz789";
189        assert_eq!(extract_session_id(cookies_no_session), None);
190
191        let cookies_with_spaces = " sessionid=def456 ; other=value";
192        assert_eq!(extract_session_id(cookies_with_spaces), Some("def456".to_string()));
193    }
194
195    #[test]
196    fn test_gift_details_struct() {
197        let details = GiftDetails { gift_name: "Test Game".to_string(), package_id: 12345, owned: false };
198        assert_eq!(details.gift_name, "Test Game");
199        assert_eq!(details.package_id, 12345);
200        assert!(!details.owned);
201    }
202}