Skip to main content

steam_user/types/
inventory.rs

1//! Economy/inventory item types.
2
3use serde::{Deserialize, Serialize};
4
5/// Represents an app that has items in the user's inventory.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ActiveInventory {
8    /// The Steam App ID (e.g., 730 for CS:GO, 440 for TF2).
9    pub app_id: u32,
10    /// URL to the game's icon image.
11    pub game_icon: Option<String>,
12    /// The display name of the game.
13    pub game_name: String,
14    /// Number of items in the inventory for this app.
15    pub count: u32,
16}
17
18// ── Steam Inventory API response types ──────────────────────────────────────
19
20fn default_instance_id() -> String {
21    "0".to_string()
22}
23
24/// A single asset entry from the Steam inventory API response.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct InventoryAsset {
27    pub appid: u32,
28    pub contextid: String,
29    pub assetid: String,
30    pub classid: String,
31    #[serde(default = "default_instance_id")]
32    pub instanceid: String,
33    pub amount: String,
34}
35
36/// A sub-description entry within an [`InventoryDescription`].
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct InventoryDescriptionEntry {
39    #[serde(rename = "type")]
40    pub desc_type: Option<String>,
41    #[serde(default)]
42    pub value: String,
43    pub name: Option<String>,
44    pub color: Option<String>,
45}
46
47/// An action link for an inventory item (e.g. "Inspect in Game...").
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct InventoryAction {
50    pub link: String,
51    pub name: String,
52}
53
54/// An asset property entry from the `asset_properties` array.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct AssetPropertyEntry {
57    pub propertyid: u32,
58    pub string_value: Option<String>,
59    pub int_value: Option<String>,
60    pub float_value: Option<String>,
61    pub name: Option<String>,
62}
63
64/// Top-level asset properties object from the API response.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AssetProperties {
67    pub appid: u32,
68    pub contextid: String,
69    pub assetid: String,
70    pub asset_properties: Vec<AssetPropertyEntry>,
71}
72
73/// A tag on an inventory item from the Steam API.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct InventoryApiTag {
76    pub category: String,
77    pub internal_name: String,
78    #[serde(default)]
79    pub localized_category_name: String,
80    #[serde(default)]
81    pub localized_tag_name: String,
82    pub color: Option<String>,
83}
84
85/// A full description object from the Steam inventory API response.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct InventoryDescription {
88    pub appid: u32,
89    pub classid: String,
90    #[serde(default = "default_instance_id")]
91    pub instanceid: String,
92    #[serde(default)]
93    pub currency: i32,
94    pub background_color: Option<String>,
95    pub icon_url: String,
96    pub icon_url_large: Option<String>,
97    #[serde(default)]
98    pub descriptions: Vec<InventoryDescriptionEntry>,
99    #[serde(default)]
100    pub owner_descriptions: Vec<InventoryDescriptionEntry>,
101    #[serde(default)]
102    pub tradable: i32,
103    #[serde(default)]
104    pub actions: Vec<InventoryAction>,
105    #[serde(default)]
106    pub name: String,
107    pub name_color: Option<String>,
108    #[serde(rename = "type", default)]
109    pub item_type: String,
110    #[serde(default)]
111    pub market_name: String,
112    #[serde(default)]
113    pub market_hash_name: String,
114    #[serde(default)]
115    pub market_actions: Vec<InventoryAction>,
116    #[serde(default)]
117    pub commodity: i32,
118    pub market_tradable_restriction: Option<i32>,
119    pub market_marketable_restriction: Option<i32>,
120    #[serde(default)]
121    pub marketable: i32,
122    #[serde(default)]
123    pub tags: Vec<InventoryApiTag>,
124    #[serde(default)]
125    pub fraudwarnings: Vec<String>,
126    pub sealed: Option<i32>,
127    pub sealed_type: Option<i32>,
128    pub market_bucket_group_name: Option<String>,
129    pub market_bucket_group_id: Option<String>,
130}
131
132/// The full inventory API response from Steam.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct InventoryResponse {
135    #[serde(default)]
136    pub assets: Vec<InventoryAsset>,
137    #[serde(default)]
138    pub descriptions: Vec<InventoryDescription>,
139    #[serde(default)]
140    pub asset_properties: Vec<AssetProperties>,
141    #[serde(default)]
142    pub success: i32,
143    pub total_inventory_count: Option<i32>,
144    #[serde(default)]
145    pub more_items: bool,
146    pub last_assetid: Option<String>,
147    pub rwgrsn: Option<i32>,
148}
149
150// ── Processed item types ────────────────────────────────────────────────────
151
152/// An item in a Steam inventory (merged asset + description).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct EconItem {
155    /// Asset ID (unique within inventory).
156    pub assetid: u64,
157    /// Class ID.
158    pub classid: u64,
159    /// Instance ID.
160    pub instanceid: u64,
161    /// App ID the item belongs to.
162    pub appid: u32,
163    /// Context ID within the app.
164    pub contextid: u64,
165    /// Stack amount.
166    pub amount: u32,
167    /// Position in inventory.
168    pub pos: Option<u32>,
169
170    /// Shared reference to the item's complex metadata (name, tags, colors,
171    /// etc.). Many items in an inventory (e.g., 100 identical cases) share
172    /// the same classid/instanceid and therefore the exact same description
173    /// data. Pointing to an Arc avoids massive duplicate allocations.
174    pub desc: std::sync::Arc<InventoryDescription>,
175
176    /// The Steam64 ID of the account that owns this item. Propagated
177    /// immediately after network fetch.
178    #[serde(default)]
179    pub owner_steam_id: Option<steamid::SteamID>,
180}
181
182impl InventoryDescription {
183    /// Returns `true` when this description represents a container
184    /// (weapon case / capsule / sticker pack / etc.).
185    /// See [`crate::utils::is_inventory_container_item`] for the detection
186    /// logic.
187    pub fn is_container(&self) -> bool {
188        crate::utils::is_inventory_container_item(&self.item_type, &self.market_hash_name, self.tags.iter().map(|t| (t.category.as_str(), t.localized_tag_name.as_str())))
189    }
190}
191
192impl EconItem {
193    /// Create an `EconItem` from a typed asset and a shared description.
194    ///
195    /// Returns `Err(SteamUserError::MalformedResponse)` if any of the integer
196    /// ID fields fail to parse. Use this instead of silently substituting
197    /// zeros — a zero assetid/classid/instanceid is a real Steam value
198    /// (default/empty) and would otherwise be indistinguishable from a parse
199    /// failure.
200    pub fn try_from_inventory_data(asset: &InventoryAsset, desc: std::sync::Arc<InventoryDescription>) -> Result<Self, crate::error::SteamUserError> {
201        use crate::error::SteamUserError;
202        let assetid = asset.assetid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem assetid {:?}: {e}", asset.assetid)))?;
203        let classid = asset.classid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem classid {:?}: {e}", asset.classid)))?;
204        let instanceid = asset.instanceid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem instanceid {:?}: {e}", asset.instanceid)))?;
205        let contextid = asset.contextid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem contextid {:?}: {e}", asset.contextid)))?;
206        let amount = asset.amount.parse::<u32>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem amount {:?}: {e}", asset.amount)))?;
207        Ok(Self { assetid, classid, instanceid, appid: asset.appid, contextid, amount, pos: None, owner_steam_id: None, desc })
208    }
209
210    /// Get the full icon URL.
211    pub fn get_icon_url(&self) -> String {
212        if self.desc.icon_url.starts_with("http") {
213            self.desc.icon_url.clone()
214        } else {
215            format!("https://community.cloudflare.steamstatic.com/economy/image/{}", self.desc.icon_url)
216        }
217    }
218
219    /// Returns true if this item is currently listed on the Steam Community
220    /// Market. Mirrors Steam JS: `if (description.sealed &&
221    /// description.sealed_type == 1)` Note: `sealed: 0` is falsy in JS, so
222    /// we must check `!= 0`.
223    pub fn is_listed_on_market(&self) -> bool {
224        self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type == Some(1)
225    }
226
227    /// Returns true if the item is trade protected (provisional), meaning it
228    /// cannot be modified, consumed, or transferred.
229    /// Mirrors Steam JS: `else if (description.sealed)` — sealed is truthy
230    /// (non-zero) but not listed.
231    pub fn is_trade_protected(&self) -> bool {
232        self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type != Some(1)
233    }
234
235    /// Returns `true` when this item is a container (weapon case / capsule /
236    /// sticker pack / etc.). Delegates to
237    /// [`InventoryDescription::is_container`].
238    pub fn is_container(&self) -> bool {
239        self.desc.is_container()
240    }
241
242    /// Returns the trade restriction cooldown text and parsed date if the item
243    /// is trade-protected.
244    pub fn get_trade_protection_expired(&self) -> Option<(String, Option<chrono::DateTime<chrono::Utc>>)> {
245        let text = self.desc.owner_descriptions.iter().find_map(|entry| if entry.value.contains("trade-protected") { Some(entry.value.clone()) } else { None })?;
246
247        // Format is often something like "until Mar 28, 2026 (7:00:00) GMT"
248        // Let's attempt to extract the date string inside it.
249        let parsed_date = text.split("until ").nth(1).and_then(|d| {
250            // Remove any trailing whitespace and the word GMT if present to normalize
251            let d_clean = d.trim().replace(" GMT", "");
252            // e.g. "Mar 28, 2026 (7:00:00)"
253            // Use formats %b %e, %Y (%H:%M:%S) which handles space-padded days,
254            // and we parse it properly into chrono structures
255            chrono::NaiveDateTime::parse_from_str(&d_clean, "%b %d, %Y (%H:%M:%S)").or_else(|_| chrono::NaiveDateTime::parse_from_str(&d_clean, "%b %e, %Y (%H:%M:%S)")).ok().map(|naive| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc))
256        });
257
258        Some((text, parsed_date))
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_trade_cooldown_parsing() {
268        let mut desc = InventoryDescription {
269            appid: 730,
270            classid: "123".into(),
271            instanceid: "0".into(),
272            currency: 0,
273            background_color: None,
274            icon_url: "".into(),
275            icon_url_large: None,
276            descriptions: vec![],
277            owner_descriptions: vec![
278                InventoryDescriptionEntry { desc_type: Some("html".into()), value: " ".into(), name: None, color: None },
279                InventoryDescriptionEntry {
280                    desc_type: Some("html".into()),
281                    value: "⇆ This item is trade-protected and cannot be consumed, modified, or transferred until Mar 28, 2026 (7:00:00) GMT".into(),
282                    name: None,
283                    color: Some("e4ae39".into()),
284                },
285            ],
286            tradable: 0,
287            actions: vec![],
288            name: "Test Case".into(),
289            name_color: None,
290            item_type: "".into(),
291            market_name: "".into(),
292            market_hash_name: "".into(),
293            market_actions: vec![],
294            commodity: 0,
295            market_tradable_restriction: None,
296            market_marketable_restriction: None,
297            marketable: 0,
298            tags: vec![],
299            fraudwarnings: vec![],
300            sealed: None,
301            sealed_type: None,
302            market_bucket_group_name: None,
303            market_bucket_group_id: None,
304        };
305
306        let asset = InventoryAsset {
307            appid: 730,
308            contextid: "2".into(),
309            assetid: "1".into(),
310            classid: "123".into(),
311            instanceid: "0".into(),
312            amount: "1".into(),
313        };
314
315        let item = EconItem::try_from_inventory_data(&asset, std::sync::Arc::new(desc.clone())).expect("test asset has valid integer IDs");
316
317        let cooldown = item.get_trade_protection_expired();
318        assert!(cooldown.is_some());
319
320        let (text, date) = cooldown.unwrap();
321        assert!(text.contains("until Mar 28, 2026 (7:00:00) GMT"));
322
323        let date = date.expect("Failed to parse date");
324        assert_eq!(date.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-03-28T07:00:00Z");
325
326        // Test with different spacing/formatting just in case
327        desc.owner_descriptions[1].value = "⇆ This item is trade-protected and cannot be consumed, modified, or transferred until Mar  8, 2026 (14:30:00) GMT".into();
328        let item2 = EconItem::try_from_inventory_data(&asset, std::sync::Arc::new(desc)).expect("test asset has valid integer IDs");
329
330        let (_, date2) = item2.get_trade_protection_expired().unwrap();
331        let date2 = date2.expect("Failed to parse padded date");
332        assert_eq!(date2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-03-08T14:30:00Z");
333    }
334}