steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Economy/inventory item types.

use serde::{Deserialize, Serialize};

/// Represents an app that has items in the user's inventory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveInventory {
    /// The Steam App ID (e.g., 730 for CS:GO, 440 for TF2).
    pub app_id: u32,
    /// URL to the game's icon image.
    pub game_icon: Option<String>,
    /// The display name of the game.
    pub game_name: String,
    /// Number of items in the inventory for this app.
    pub count: u32,
}

// ── Steam Inventory API response types ──────────────────────────────────────

fn default_instance_id() -> String {
    "0".to_string()
}

/// A single asset entry from the Steam inventory API response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryAsset {
    pub appid: u32,
    pub contextid: String,
    pub assetid: String,
    pub classid: String,
    #[serde(default = "default_instance_id")]
    pub instanceid: String,
    pub amount: String,
}

/// A sub-description entry within an [`InventoryDescription`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryDescriptionEntry {
    #[serde(rename = "type")]
    pub desc_type: Option<String>,
    #[serde(default)]
    pub value: String,
    pub name: Option<String>,
    pub color: Option<String>,
}

/// An action link for an inventory item (e.g. "Inspect in Game...").
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryAction {
    pub link: String,
    pub name: String,
}

/// An asset property entry from the `asset_properties` array.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetPropertyEntry {
    pub propertyid: u32,
    pub string_value: Option<String>,
    pub int_value: Option<String>,
    pub float_value: Option<String>,
    pub name: Option<String>,
}

/// Top-level asset properties object from the API response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetProperties {
    pub appid: u32,
    pub contextid: String,
    pub assetid: String,
    pub asset_properties: Vec<AssetPropertyEntry>,
}

/// A tag on an inventory item from the Steam API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryApiTag {
    pub category: String,
    pub internal_name: String,
    #[serde(default)]
    pub localized_category_name: String,
    #[serde(default)]
    pub localized_tag_name: String,
    pub color: Option<String>,
}

/// A full description object from the Steam inventory API response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryDescription {
    pub appid: u32,
    pub classid: String,
    #[serde(default = "default_instance_id")]
    pub instanceid: String,
    #[serde(default)]
    pub currency: i32,
    pub background_color: Option<String>,
    pub icon_url: String,
    pub icon_url_large: Option<String>,
    #[serde(default)]
    pub descriptions: Vec<InventoryDescriptionEntry>,
    #[serde(default)]
    pub owner_descriptions: Vec<InventoryDescriptionEntry>,
    #[serde(default)]
    pub tradable: i32,
    #[serde(default)]
    pub actions: Vec<InventoryAction>,
    #[serde(default)]
    pub name: String,
    pub name_color: Option<String>,
    #[serde(rename = "type", default)]
    pub item_type: String,
    #[serde(default)]
    pub market_name: String,
    #[serde(default)]
    pub market_hash_name: String,
    #[serde(default)]
    pub market_actions: Vec<InventoryAction>,
    #[serde(default)]
    pub commodity: i32,
    pub market_tradable_restriction: Option<i32>,
    pub market_marketable_restriction: Option<i32>,
    #[serde(default)]
    pub marketable: i32,
    #[serde(default)]
    pub tags: Vec<InventoryApiTag>,
    #[serde(default)]
    pub fraudwarnings: Vec<String>,
    pub sealed: Option<i32>,
    pub sealed_type: Option<i32>,
    pub market_bucket_group_name: Option<String>,
    pub market_bucket_group_id: Option<String>,
}

/// The full inventory API response from Steam.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryResponse {
    #[serde(default)]
    pub assets: Vec<InventoryAsset>,
    #[serde(default)]
    pub descriptions: Vec<InventoryDescription>,
    #[serde(default)]
    pub asset_properties: Vec<AssetProperties>,
    #[serde(default)]
    pub success: i32,
    pub total_inventory_count: Option<i32>,
    #[serde(default)]
    pub more_items: bool,
    pub last_assetid: Option<String>,
    pub rwgrsn: Option<i32>,
}

// ── Processed item types ────────────────────────────────────────────────────

/// An item in a Steam inventory (merged asset + description).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EconItem {
    /// Asset ID (unique within inventory).
    pub assetid: u64,
    /// Class ID.
    pub classid: u64,
    /// Instance ID.
    pub instanceid: u64,
    /// App ID the item belongs to.
    pub appid: u32,
    /// Context ID within the app.
    pub contextid: u64,
    /// Stack amount.
    pub amount: u32,
    /// Position in inventory.
    pub pos: Option<u32>,

    /// Shared reference to the item's complex metadata (name, tags, colors,
    /// etc.). Many items in an inventory (e.g., 100 identical cases) share
    /// the same classid/instanceid and therefore the exact same description
    /// data. Pointing to an Arc avoids massive duplicate allocations.
    pub desc: std::sync::Arc<InventoryDescription>,

    /// The Steam64 ID of the account that owns this item. Propagated
    /// immediately after network fetch.
    #[serde(default)]
    pub owner_steam_id: Option<steamid::SteamID>,
}

impl InventoryDescription {
    /// Returns `true` when this description represents a container
    /// (weapon case / capsule / sticker pack / etc.).
    /// See [`crate::utils::is_inventory_container_item`] for the detection
    /// logic.
    pub fn is_container(&self) -> bool {
        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())))
    }
}

impl EconItem {
    /// Create an `EconItem` from a typed asset and a shared description.
    ///
    /// Returns `Err(SteamUserError::MalformedResponse)` if any of the integer
    /// ID fields fail to parse. Use this instead of silently substituting
    /// zeros — a zero assetid/classid/instanceid is a real Steam value
    /// (default/empty) and would otherwise be indistinguishable from a parse
    /// failure.
    pub fn try_from_inventory_data(asset: &InventoryAsset, desc: std::sync::Arc<InventoryDescription>) -> Result<Self, crate::error::SteamUserError> {
        use crate::error::SteamUserError;
        let assetid = asset.assetid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem assetid {:?}: {e}", asset.assetid)))?;
        let classid = asset.classid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem classid {:?}: {e}", asset.classid)))?;
        let instanceid = asset.instanceid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem instanceid {:?}: {e}", asset.instanceid)))?;
        let contextid = asset.contextid.parse::<u64>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem contextid {:?}: {e}", asset.contextid)))?;
        let amount = asset.amount.parse::<u32>().map_err(|e| SteamUserError::MalformedResponse(format!("EconItem amount {:?}: {e}", asset.amount)))?;
        Ok(Self { assetid, classid, instanceid, appid: asset.appid, contextid, amount, pos: None, owner_steam_id: None, desc })
    }

    /// Get the full icon URL.
    pub fn get_icon_url(&self) -> String {
        if self.desc.icon_url.starts_with("http") {
            self.desc.icon_url.clone()
        } else {
            format!("https://community.cloudflare.steamstatic.com/economy/image/{}", self.desc.icon_url)
        }
    }

    /// Returns true if this item is currently listed on the Steam Community
    /// Market. Mirrors Steam JS: `if (description.sealed &&
    /// description.sealed_type == 1)` Note: `sealed: 0` is falsy in JS, so
    /// we must check `!= 0`.
    pub fn is_listed_on_market(&self) -> bool {
        self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type == Some(1)
    }

    /// Returns true if the item is trade protected (provisional), meaning it
    /// cannot be modified, consumed, or transferred.
    /// Mirrors Steam JS: `else if (description.sealed)` — sealed is truthy
    /// (non-zero) but not listed.
    pub fn is_trade_protected(&self) -> bool {
        self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type != Some(1)
    }

    /// Returns `true` when this item is a container (weapon case / capsule /
    /// sticker pack / etc.). Delegates to
    /// [`InventoryDescription::is_container`].
    pub fn is_container(&self) -> bool {
        self.desc.is_container()
    }

    /// Returns the trade restriction cooldown text and parsed date if the item
    /// is trade-protected.
    pub fn get_trade_protection_expired(&self) -> Option<(String, Option<chrono::DateTime<chrono::Utc>>)> {
        let text = self.desc.owner_descriptions.iter().find_map(|entry| if entry.value.contains("trade-protected") { Some(entry.value.clone()) } else { None })?;

        // Format is often something like "until Mar 28, 2026 (7:00:00) GMT"
        // Let's attempt to extract the date string inside it.
        let parsed_date = text.split("until ").nth(1).and_then(|d| {
            // Remove any trailing whitespace and the word GMT if present to normalize
            let d_clean = d.trim().replace(" GMT", "");
            // e.g. "Mar 28, 2026 (7:00:00)"
            // Use formats %b %e, %Y (%H:%M:%S) which handles space-padded days,
            // and we parse it properly into chrono structures
            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))
        });

        Some((text, parsed_date))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_trade_cooldown_parsing() {
        let mut desc = InventoryDescription {
            appid: 730,
            classid: "123".into(),
            instanceid: "0".into(),
            currency: 0,
            background_color: None,
            icon_url: "".into(),
            icon_url_large: None,
            descriptions: vec![],
            owner_descriptions: vec![
                InventoryDescriptionEntry { desc_type: Some("html".into()), value: " ".into(), name: None, color: None },
                InventoryDescriptionEntry {
                    desc_type: Some("html".into()),
                    value: "⇆ This item is trade-protected and cannot be consumed, modified, or transferred until Mar 28, 2026 (7:00:00) GMT".into(),
                    name: None,
                    color: Some("e4ae39".into()),
                },
            ],
            tradable: 0,
            actions: vec![],
            name: "Test Case".into(),
            name_color: None,
            item_type: "".into(),
            market_name: "".into(),
            market_hash_name: "".into(),
            market_actions: vec![],
            commodity: 0,
            market_tradable_restriction: None,
            market_marketable_restriction: None,
            marketable: 0,
            tags: vec![],
            fraudwarnings: vec![],
            sealed: None,
            sealed_type: None,
            market_bucket_group_name: None,
            market_bucket_group_id: None,
        };

        let asset = InventoryAsset {
            appid: 730,
            contextid: "2".into(),
            assetid: "1".into(),
            classid: "123".into(),
            instanceid: "0".into(),
            amount: "1".into(),
        };

        let item = EconItem::try_from_inventory_data(&asset, std::sync::Arc::new(desc.clone())).expect("test asset has valid integer IDs");

        let cooldown = item.get_trade_protection_expired();
        assert!(cooldown.is_some());

        let (text, date) = cooldown.unwrap();
        assert!(text.contains("until Mar 28, 2026 (7:00:00) GMT"));

        let date = date.expect("Failed to parse date");
        assert_eq!(date.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-03-28T07:00:00Z");

        // Test with different spacing/formatting just in case
        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();
        let item2 = EconItem::try_from_inventory_data(&asset, std::sync::Arc::new(desc)).expect("test asset has valid integer IDs");

        let (_, date2) = item2.get_trade_protection_expired().unwrap();
        let date2 = date2.expect("Failed to parse padded date");
        assert_eq!(date2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-03-08T14:30:00Z");
    }
}