Skip to main content

steam_client/services/
econ.rs

1//! Steam Economy features.
2//!
3//! This module provides access to Steam's economy system including:
4//! - Trade URL management
5//! - Asset class information lookup
6//! - Emoticon lists
7//! - Profile item management (backgrounds, avatar frames, etc.)
8
9use std::collections::HashMap;
10
11use steamid::SteamID;
12
13use crate::{error::SteamError, SteamClient};
14
15/// Asset class information for an item.
16#[derive(Debug, Clone)]
17pub struct AssetClassInfo {
18    /// App ID
19    pub appid: i32,
20    /// Class ID  
21    pub classid: u64,
22    /// Instance ID
23    pub instanceid: u64,
24    /// Display name
25    pub name: String,
26    /// Market hash name for trading
27    pub market_hash_name: Option<String>,
28    /// Market display name
29    pub market_name: Option<String>,
30    /// Name color (hex)
31    pub name_color: Option<String>,
32    /// Background color (hex)
33    pub background_color: Option<String>,
34    /// Item type description
35    pub item_type: Option<String>,
36    /// Icon URL (append to Steam CDN base URL)
37    pub icon_url: Option<String>,
38    /// Large icon URL
39    pub icon_url_large: Option<String>,
40    /// Is item tradable
41    pub tradable: bool,
42    /// Is item marketable
43    pub marketable: bool,
44    /// Is item a commodity (stackable on market)
45    pub commodity: bool,
46}
47
48/// A class to look up asset info for.
49#[derive(Debug, Clone)]
50pub struct AssetClass {
51    /// Class ID of the asset
52    pub classid: u64,
53    /// Instance ID (optional, defaults to 0)
54    pub instanceid: Option<u64>,
55}
56
57/// Trade URL information.
58#[derive(Debug, Clone)]
59pub struct TradeUrl {
60    /// The trade offer access token
61    pub token: String,
62    /// Full trade URL
63    pub url: String,
64}
65
66/// Emoticon information.
67#[derive(Debug, Clone)]
68pub struct Emoticon {
69    /// Emoticon name (e.g., "steamhappy")
70    pub name: String,
71    /// Number of times used
72    pub use_count: u32,
73    /// Timestamp last used
74    pub time_last_used: Option<u32>,
75    /// Timestamp received
76    pub time_received: Option<u32>,
77    /// App ID that granted this emoticon
78    pub appid: Option<u32>,
79}
80
81/// Profile item information.
82#[derive(Debug, Clone)]
83pub struct ProfileItem {
84    /// Community item ID
85    pub communityitemid: u64,
86    /// Large image URL
87    pub image_large: Option<String>,
88    /// Small image URL  
89    pub image_small: Option<String>,
90    /// Item name
91    pub name: Option<String>,
92    /// Item title
93    pub item_title: Option<String>,
94    /// Item description
95    pub item_description: Option<String>,
96    /// App ID that granted this item
97    pub appid: Option<u32>,
98    /// Item type
99    pub item_type: Option<u32>,
100    /// Item class
101    pub item_class: Option<u32>,
102    /// Movie WebM URL
103    pub movie_webm: Option<String>,
104    /// Movie MP4 URL
105    pub movie_mp4: Option<String>,
106}
107
108/// Owned profile items organized by category.
109#[derive(Debug, Clone, Default)]
110pub struct OwnedProfileItems {
111    /// Profile backgrounds
112    pub profile_backgrounds: Vec<ProfileItem>,
113    /// Mini profile backgrounds
114    pub mini_profile_backgrounds: Vec<ProfileItem>,
115    /// Avatar frames
116    pub avatar_frames: Vec<ProfileItem>,
117    /// Animated avatars
118    pub animated_avatars: Vec<ProfileItem>,
119    /// Profile modifiers
120    pub profile_modifiers: Vec<ProfileItem>,
121}
122
123/// Equipped profile items.
124#[derive(Debug, Clone, Default)]
125pub struct EquippedProfileItems {
126    /// Profile background
127    pub profile_background: Option<ProfileItem>,
128    /// Mini profile background
129    pub mini_profile_background: Option<ProfileItem>,
130    /// Avatar frame
131    pub avatar_frame: Option<ProfileItem>,
132    /// Animated avatar
133    pub animated_avatar: Option<ProfileItem>,
134    /// Profile modifier
135    pub profile_modifier: Option<ProfileItem>,
136}
137
138const STEAM_CDN_BASE: &str = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/";
139
140impl SteamClient {
141    /// Get asset class information for items in a specific app.
142    ///
143    /// This fetches detailed information about items based on their class IDs.
144    ///
145    /// # Arguments
146    ///
147    /// * `language` - Language code for descriptions (e.g., "english",
148    ///   "german")
149    /// * `appid` - App ID the items belong to
150    /// * `classes` - List of asset classes to look up
151    ///
152    /// # Example
153    ///
154    /// ```rust,ignore
155    /// let classes = vec![
156    ///     AssetClass { classid: 123456789, instanceid: None },
157    ///     AssetClass { classid: 987654321, instanceid: Some(0) },
158    /// ];
159    /// let info = client.get_asset_class_info("english", 730, classes).await?;
160    /// for (classid, item) in info {
161    ///     tracing::info!("{}: {}", classid, item.name);
162    /// }
163    /// ```
164    pub async fn get_asset_class_info(&mut self, language: &str, appid: u32, classes: Vec<AssetClass>) -> Result<HashMap<u64, AssetClassInfo>, SteamError> {
165        if !self.is_logged_in() {
166            return Err(SteamError::NotLoggedOn);
167        }
168
169        let request_classes: Vec<_> = classes.iter().map(|c| steam_protos::c_econ_get_asset_class_info_request::Class { classid: Some(c.classid), instanceid: c.instanceid }).collect();
170
171        let request = steam_protos::CEconGetAssetClassInfoRequest { language: Some(language.to_string()), appid: Some(appid), classes: request_classes };
172
173        let response: steam_protos::CEconGetAssetClassInfoResponse = self.send_unified_request_and_wait("Econ.GetAssetClassInfo#1", &request).await?;
174
175        let mut result = HashMap::new();
176        for description in response.descriptions {
177            if let Some(classid) = description.classid {
178                result.insert(
179                    classid,
180                    AssetClassInfo {
181                        appid: description.appid.unwrap_or(0),
182                        classid,
183                        instanceid: description.instanceid.unwrap_or(0),
184                        name: description.name.unwrap_or_default(),
185                        market_hash_name: description.market_hash_name,
186                        market_name: description.market_name,
187                        name_color: description.name_color,
188                        background_color: description.background_color,
189                        item_type: description.r#type,
190                        icon_url: description.icon_url.map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
191                        icon_url_large: description.icon_url_large.map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
192                        tradable: description.tradable.unwrap_or(false),
193                        marketable: description.marketable.unwrap_or(false),
194                        commodity: description.commodity.unwrap_or(false),
195                    },
196                );
197            }
198        }
199
200        Ok(result)
201    }
202
203    /// Get your account's trade URL.
204    ///
205    /// The trade URL can be shared with others to initiate trade offers.
206    ///
207    /// # Example
208    ///
209    /// ```rust,ignore
210    /// let trade_url = client.get_trade_url().await?;
211    /// tracing::info!("Trade URL: {}", trade_url.url);
212    /// tracing::info!("Token: {}", trade_url.token);
213    /// ```
214    pub async fn get_trade_url(&mut self) -> Result<TradeUrl, SteamError> {
215        if !self.is_logged_in() {
216            return Err(SteamError::NotLoggedOn);
217        }
218
219        let request = steam_protos::CEconGetTradeOfferAccessTokenRequest { generate_new_token: Some(false) };
220
221        // Send and wait for response
222        let response: steam_protos::CEconGetTradeOfferAccessTokenResponse = self.send_unified_request_and_wait("Econ.GetTradeOfferAccessToken#1", &request).await?;
223
224        let token = response.trade_offer_access_token.unwrap_or_default();
225        let account_id = self.steam_id.map(|id| id.account_id).unwrap_or(0);
226
227        Ok(TradeUrl {
228            token: token.clone(),
229            url: format!("https://steamcommunity.com/tradeoffer/new/?partner={}&token={}", account_id, token),
230        })
231    }
232
233    /// Generate a new trade URL for your account.
234    ///
235    /// This invalidates the old trade URL and creates a new one.
236    ///
237    /// # Example
238    ///
239    /// ```rust,ignore
240    /// let new_url = client.change_trade_url().await?;
241    /// tracing::info!("New trade URL: {}", new_url.url);
242    /// ```
243    pub async fn change_trade_url(&mut self) -> Result<TradeUrl, SteamError> {
244        if !self.is_logged_in() {
245            return Err(SteamError::NotLoggedOn);
246        }
247
248        let request = steam_protos::CEconGetTradeOfferAccessTokenRequest { generate_new_token: Some(true) };
249
250        // Send and wait for response
251        let response: steam_protos::CEconGetTradeOfferAccessTokenResponse = self.send_unified_request_and_wait("Econ.GetTradeOfferAccessToken#1", &request).await?;
252
253        let token = response.trade_offer_access_token.unwrap_or_default();
254        let account_id = self.steam_id.map(|id| id.account_id).unwrap_or(0);
255
256        Ok(TradeUrl {
257            token: token.clone(),
258            url: format!("https://steamcommunity.com/tradeoffer/new/?partner={}&token={}", account_id, token),
259        })
260    }
261
262    /// Get the list of emoticons your account can use.
263    ///
264    /// # Example
265    ///
266    /// ```rust,ignore
267    /// let emoticons = client.get_emoticon_list().await?;
268    /// for (name, emoticon) in emoticons {
269    ///     tracing::info!(":{}: - used {} times", name, emoticon.use_count);
270    /// }
271    /// ```
272    pub async fn get_emoticon_list(&mut self) -> Result<HashMap<String, Emoticon>, SteamError> {
273        if !self.is_logged_in() {
274            return Err(SteamError::NotLoggedOn);
275        }
276
277        let request = steam_protos::CPlayerGetEmoticonListRequest {};
278
279        // Send and wait for response
280        let response: steam_protos::CPlayerGetEmoticonListResponse = self.send_unified_request_and_wait("Player.GetEmoticonList#1", &request).await?;
281
282        let mut emoticons = HashMap::new();
283        for emoticon in response.emoticons {
284            if let Some(name) = emoticon.name {
285                let name_clone = name.clone();
286                emoticons.insert(
287                    name_clone.clone(),
288                    Emoticon {
289                        name: name_clone,
290                        use_count: emoticon.use_count.unwrap_or(0),
291                        time_last_used: emoticon.time_last_used,
292                        time_received: emoticon.time_received,
293                        appid: emoticon.appid,
294                    },
295                );
296            }
297        }
298
299        Ok(emoticons)
300    }
301
302    /// Get a listing of profile items you own.
303    ///
304    /// This returns all profile customization items you own, organized by
305    /// category.
306    ///
307    /// # Arguments
308    ///
309    /// * `language` - Language code for item descriptions (defaults to
310    ///   "english")
311    ///
312    /// # Example
313    ///
314    /// ```rust,ignore
315    /// let items = client.get_owned_profile_items(Some("english")).await?;
316    /// tracing::info!("You own {} profile backgrounds", items.profile_backgrounds.len());
317    /// tracing::info!("You own {} avatar frames", items.avatar_frames.len());
318    /// ```
319    pub async fn get_owned_profile_items(&mut self, language: Option<&str>) -> Result<OwnedProfileItems, SteamError> {
320        if !self.is_logged_in() {
321            return Err(SteamError::NotLoggedOn);
322        }
323
324        let request = steam_protos::CPlayerGetProfileItemsOwnedRequest { language: Some(language.unwrap_or("english").to_string()) };
325
326        // Send and wait for response
327        let response: steam_protos::CPlayerGetProfileItemsOwnedResponse = self.send_unified_request_and_wait("Player.GetProfileItemsOwned#1", &request).await?;
328
329        Ok(OwnedProfileItems {
330            profile_backgrounds: response.profile_backgrounds.iter().filter_map(process_profile_item).collect(),
331            mini_profile_backgrounds: response.mini_profile_backgrounds.iter().filter_map(process_profile_item).collect(),
332            avatar_frames: response.avatar_frames.iter().filter_map(process_profile_item).collect(),
333            animated_avatars: response.animated_avatars.iter().filter_map(process_profile_item).collect(),
334            profile_modifiers: response.profile_modifiers.iter().filter_map(process_profile_item).collect(),
335        })
336    }
337
338    /// Get a user's equipped profile items.
339    ///
340    /// This returns the profile customization items currently equipped by a
341    /// user.
342    ///
343    /// # Arguments
344    ///
345    /// * `steam_id` - The Steam ID of the user to look up
346    /// * `language` - Language code for item descriptions (defaults to
347    ///   "english")
348    ///
349    /// # Example
350    ///
351    /// ```rust,ignore
352    /// let items = client.get_equipped_profile_items(friend_id, Some("english")).await?;
353    /// if let Some(bg) = items.profile_background {
354    ///     tracing::info!("Profile background: {}", bg.name.unwrap_or_default());
355    /// }
356    /// ```
357    pub async fn get_equipped_profile_items(&mut self, steam_id: SteamID, language: Option<&str>) -> Result<EquippedProfileItems, SteamError> {
358        if !self.is_logged_in() {
359            return Err(SteamError::NotLoggedOn);
360        }
361
362        let request = steam_protos::CPlayerGetProfileItemsEquippedRequest { steamid: Some(steam_id.steam_id64()), language: Some(language.unwrap_or("english").to_string()) };
363
364        // Send and wait for response
365        let response: steam_protos::CPlayerGetProfileItemsEquippedResponse = self.send_unified_request_and_wait("Player.GetProfileItemsEquipped#1", &request).await?;
366
367        Ok(EquippedProfileItems {
368            profile_background: response.profile_background.as_ref().and_then(process_profile_item),
369            mini_profile_background: response.mini_profile_background.as_ref().and_then(process_profile_item),
370            avatar_frame: response.avatar_frame.as_ref().and_then(process_profile_item),
371            animated_avatar: response.animated_avatar.as_ref().and_then(process_profile_item),
372            profile_modifier: response.profile_modifier.as_ref().and_then(process_profile_item),
373        })
374    }
375
376    /// Set your current profile background.
377    ///
378    /// # Arguments
379    ///
380    /// * `background_asset_id` - The community item ID of the background to set
381    ///
382    /// # Example
383    ///
384    /// ```rust,ignore
385    /// // Get owned items first
386    /// let items = client.get_owned_profile_items(None).await?;
387    /// if let Some(bg) = items.profile_backgrounds.first() {
388    ///     client.set_profile_background(bg.communityitemid).await?;
389    /// }
390    /// ```
391    pub async fn set_profile_background(&mut self, background_asset_id: u64) -> Result<(), SteamError> {
392        if !self.is_logged_in() {
393            return Err(SteamError::NotLoggedOn);
394        }
395
396        let request = steam_protos::CPlayerSetProfileBackgroundRequest { communityitemid: Some(background_asset_id) };
397
398        self.send_service_method("Player.SetProfileBackground#1", &request).await
399    }
400}
401
402/// Helper to process profile item URLs by prepending the Steam CDN base.
403///
404/// This function will be used when job-based response handling is implemented
405/// to convert protobuf profile items to the public ProfileItem type.
406fn process_profile_item(item: &steam_protos::CPlayerProfileItem) -> Option<ProfileItem> {
407    // Check if item has any data
408    item.communityitemid?;
409
410    Some(ProfileItem {
411        communityitemid: item.communityitemid.unwrap_or(0),
412        image_large: item.image_large.as_ref().map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
413        image_small: item.image_small.as_ref().map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
414        name: item.name.clone(),
415        item_title: item.item_title.clone(),
416        item_description: item.item_description.clone(),
417        appid: item.appid,
418        item_type: item.item_type,
419        item_class: item.item_class,
420        movie_webm: item.movie_webm.as_ref().map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
421        movie_mp4: item.movie_mp4.as_ref().map(|s| format!("{}{}", STEAM_CDN_BASE, s)),
422    })
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_trade_url_format() {
431        let url = TradeUrl {
432            token: "abc123".to_string(),
433            url: "https://steamcommunity.com/tradeoffer/new/?partner=12345&token=abc123".to_string(),
434        };
435        assert!(url.url.contains("partner=12345"));
436        assert!(url.url.contains("token=abc123"));
437    }
438}