1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ActiveInventory {
8 pub app_id: u32,
10 pub game_icon: Option<String>,
12 pub game_name: String,
14 pub count: u32,
16}
17
18fn default_instance_id() -> String {
21 "0".to_string()
22}
23
24#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct InventoryAction {
50 pub link: String,
51 pub name: String,
52}
53
54#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct EconItem {
155 pub assetid: u64,
157 pub classid: u64,
159 pub instanceid: u64,
161 pub appid: u32,
163 pub contextid: u64,
165 pub amount: u32,
167 pub pos: Option<u32>,
169
170 pub desc: std::sync::Arc<InventoryDescription>,
175
176 #[serde(default)]
179 pub owner_steam_id: Option<steamid::SteamID>,
180}
181
182impl InventoryDescription {
183 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 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 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 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 pub fn is_trade_protected(&self) -> bool {
232 self.desc.sealed.unwrap_or(0) != 0 && self.desc.sealed_type != Some(1)
233 }
234
235 pub fn is_container(&self) -> bool {
239 self.desc.is_container()
240 }
241
242 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 let parsed_date = text.split("until ").nth(1).and_then(|d| {
250 let d_clean = d.trim().replace(" GMT", "");
252 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 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}