use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveInventory {
pub app_id: u32,
pub game_icon: Option<String>,
pub game_name: String,
pub count: u32,
}
fn default_instance_id() -> String {
"0".to_string()
}
#[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,
}
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryAction {
pub link: String,
pub name: String,
}
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetProperties {
pub appid: u32,
pub contextid: String,
pub assetid: String,
pub asset_properties: Vec<AssetPropertyEntry>,
}
#[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>,
}
#[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>,
}
#[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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EconItem {
pub assetid: u64,
pub classid: u64,
pub instanceid: u64,
pub appid: u32,
pub contextid: u64,
pub amount: u32,
pub pos: Option<u32>,
pub desc: std::sync::Arc<InventoryDescription>,
#[serde(default)]
pub owner_steam_id: Option<steamid::SteamID>,
}
impl InventoryDescription {
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 {
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 })
}
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)
}
}
pub fn is_listed_on_market(&self) -> bool {
self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type == Some(1)
}
pub fn is_trade_protected(&self) -> bool {
self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type != Some(1)
}
pub fn is_container(&self) -> bool {
self.desc.is_container()
}
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 })?;
let parsed_date = text.split("until ").nth(1).and_then(|d| {
let d_clean = d.trim().replace(" GMT", "");
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");
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");
}
}