1use steamid::SteamID;
4
5use crate::{
6 client::SteamUser,
7 endpoint::steam_endpoint,
8 error::SteamUserError,
9 types::{ActiveInventory, AppId, ContextId, EconItem, InventoryCursor, InventoryHistoryItem, InventoryHistoryResult, InventoryResponse, PriceOverview, TradePeople},
10};
11
12impl SteamUser {
13 #[steam_endpoint(GET, host = Community, path = "/inventory/{user_id}/{app_id}/{context_id}", kind = Read)]
29 pub async fn get_user_inventory_contents(&self, user_id: SteamID, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
30 let path = format!("/inventory/{}/{}/{}", user_id.steam_id64(), appid, context_id);
31
32 let mut items = Vec::new();
33 let mut start_assetid = None;
34
35 loop {
36 let mut request = self.get_path(&path).query(&[("count", "2000"), ("preserve_bbcode", "1"), ("raw_asset_properties", "1")]);
37
38 if let Some(start) = start_assetid.as_ref() {
39 request = request.query(&[("start_assetid", start)]);
40 }
41
42 let response: InventoryResponse = request.send().await?.error_for_status()?.json().await?;
43
44 tracing::info!(
45 path = %path,
46 success = response.success,
47 total_count = ?response.total_inventory_count,
48 assets = response.assets.len(),
49 descriptions = response.descriptions.len(),
50 "inventory fetch result",
51 );
52
53 if response.success != 1 {
54 tracing::warn!(success = response.success, "inventory fetch: Steam returned non-1 success, returning error");
55 return Err(SteamUserError::from_eresult(response.success));
56 }
57
58 let desc_map: std::collections::HashMap<String, std::sync::Arc<crate::types::InventoryDescription>> = response.descriptions.into_iter().map(|d| (format!("{}_{}", d.classid, d.instanceid), std::sync::Arc::new(d))).collect();
60
61 for asset in &response.assets {
62 let key = format!("{}_{}", asset.classid, asset.instanceid);
63 if let Some(desc) = desc_map.get(&key) {
64 match EconItem::try_from_inventory_data(asset, desc.clone()) {
65 Ok(mut item) => {
66 if let Some(steam_id) = self.steam_id() {
67 item.owner_steam_id = Some(steam_id);
68 }
69 items.push(item);
70 }
71 Err(e) => {
72 tracing::warn!(assetid = %asset.assetid, classid = %asset.classid, error = %e, "skipping malformed inventory asset");
73 }
74 }
75 }
76 }
77
78 if response.more_items {
80 if let Some(last) = response.last_assetid {
81 start_assetid = Some(last);
82 continue;
83 }
84 }
85
86 break;
87 }
88
89 Ok(items)
90 }
91
92 #[tracing::instrument(skip(self), fields(app_id = appid.get(), context_id = context_id.get()))]
98 pub async fn get_inventory(&self, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
99 let steam_id = self.steam_id().ok_or(SteamUserError::NotLoggedIn)?;
100 self.get_user_inventory_contents(steam_id, appid, context_id).await
101 }
102
103 #[steam_endpoint(GET, host = Community, path = "/my/inventory/json/{app_id}/{context_id}", kind = Read)]
108 pub async fn get_inventory_trading(&self, appid: AppId, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
109 let response: serde_json::Value = self.get_path(format!("/my/inventory/json/{}/{}", appid, context_id)).query(&[("trading", "1")]).send().await?.json().await?;
110
111 if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
112 let mut enriched = response;
113 let steam_id = self.steam_id().map(|id| id.steam_id64().to_string()).unwrap_or_default();
114 let context_str = context_id.to_string();
115
116 if let Some(obj) = enriched.as_object_mut() {
117 if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
118 for (_, item) in rg_inv {
119 if let Some(item_obj) = item.as_object_mut() {
120 item_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
121 item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
122 }
123 }
124 }
125 if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
126 for (_, desc) in rg_desc {
127 if let Some(desc_obj) = desc.as_object_mut() {
128 desc_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
129 desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
130 }
131 }
132 }
133 }
134 Ok(enriched)
135 } else {
136 Err(SteamUserError::MalformedResponse("Failed to fetch trading inventory".into()))
137 }
138 }
139
140 #[steam_endpoint(GET, host = Community, path = "/market/priceoverview/", kind = Read)]
147 pub async fn get_price_overview(&self, appid: AppId, market_hash_name: &str) -> Result<PriceOverview, SteamUserError> {
148 let appid_str = appid.to_string();
149 let response: PriceOverview = self
150 .get_path("/market/priceoverview/")
151 .query(&[
152 ("appid", &appid_str),
153 ("market_hash_name", &market_hash_name.to_string()),
154 ("currency", &"15".to_string()), ])
156 .send()
157 .await?
158 .json()
159 .await?;
160
161 Ok(response)
162 }
163
164 fn normalize_trade_description(description: &str) -> String {
166 const DESCRIPTION_LIST: &[&str] = &[
167 "You purchased an item on the Community Market.",
168 "You listed an item on the Community Market.",
169 "You canceled a listing on the Community Market. The item was returned to you.",
170 "Crafted",
171 "Expired",
172 "Earned a new rank and got a drop",
173 "Got an item drop",
174 "Random item drop",
175 "Purchased a gift",
176 "Earned by redeeming Steam Points",
177 "Earned by completing your Store Discovery Queue",
178 "Earned",
179 "Traded",
180 "Earned due to game play time",
181 "Listed on the Steam Community Market",
182 "Turned into Gems",
183 "Unpacked a booster pack",
184 "Purchased with Gems",
185 "Unpacked Gems from Sack",
186 "Earned by crafting",
187 "Used",
188 "Unsealed",
189 "Earned by sale purchases",
190 "Unlocked a container",
191 "Purchased from the store",
192 "You deleted",
193 "Found",
194 "Received from the Community Market",
195 "Exchanged one or more items for something different",
196 "Earned an item due to ownership of another game",
197 "Sticker applied",
198 "Sticker removed",
199 "Subscription/Seasonal Item Grant",
200 "Earned from unlocking an achievement",
201 "Moved to Storage Unit",
202 ];
203
204 if DESCRIPTION_LIST.contains(&description) {
205 return description.to_string();
206 }
207
208 if description.starts_with("You traded with ") {
209 return "You traded with".to_string();
210 }
211 if description.starts_with("Gift sent to and redeemed by ") {
212 return "Gift sent to and redeemed by".to_string();
213 }
214 if description.starts_with("Your trade with ") && description.ends_with(" was on hold, and the trade has now completed.") {
215 return "Your trade with friend was on hold, and the trade has now completed.".to_string();
216 }
217 if description.starts_with("You listed an item on the Community Market. The listing was placed on hold until") {
218 return "You listed an item on the Community Market. The listing was placed on hold until".to_string();
219 }
220 if description.starts_with("Earned in ") {
221 return "Earned in game".to_string();
222 }
223 if description.starts_with("Refunded a gift because the recipient,") && description.ends_with("declined") {
224 return "Refunded a gift because the recipient declined".to_string();
225 }
226 if description.starts_with("Your held trade with") && description.ends_with("was canceled. The items have been returned to you.") {
227 return "Your held trade with person was canceled. The items have been returned to you.".to_string();
228 }
229
230 description.to_string()
231 }
232
233 #[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/partnerinventory/", kind = Read)]
236 pub async fn get_inventory_trading_partner(&self, appid: AppId, partner: SteamID, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
237 let appid_str = appid.to_string();
238 let partner_str = partner.steam_id64().to_string();
239 let context_str = context_id.to_string();
240
241 let response: serde_json::Value = self.get_path("/tradeoffer/new/partnerinventory/").query(&[("partner", partner_str.as_str()), ("appid", appid_str.as_str()), ("contextid", context_str.as_str())]).header("Referer", "https://steamcommunity.com/tradeoffer/").send().await?.json().await?;
242
243 if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
244 let mut enriched = response;
245 let partner_str = partner.steam_id64().to_string();
246 let context_str = context_id.to_string();
247
248 if let Some(obj) = enriched.as_object_mut() {
249 if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
250 for (_, item) in rg_inv {
251 if let Some(item_obj) = item.as_object_mut() {
252 item_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
253 item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
254 }
255 }
256 }
257 if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
258 for (_, desc) in rg_desc {
259 if let Some(desc_obj) = desc.as_object_mut() {
260 desc_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
261 desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
262 }
263 }
264 }
265 }
266 Ok(enriched)
267 } else {
268 Err(SteamUserError::MalformedResponse("Failed to fetch trading partner inventory".into()))
269 }
270 }
271
272 fn parse_inventory_history_date(date_text: &str, time_text: &str) -> u64 {
275 use chrono::NaiveDateTime;
276
277 let combined = format!("{} {}", date_text, time_text);
278 if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M%P") {
280 return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
281 }
282 if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M %P") {
284 return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
285 }
286 if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %H:%M") {
288 return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
289 }
290 tracing::warn!(combined = %combined, "failed to parse inventory history date");
291 0
292 }
293
294 #[steam_endpoint(GET, host = Community, path = "/my/inventoryhistory/", kind = Read)]
304 pub async fn get_inventory_history(&self, cursor: Option<InventoryCursor>) -> Result<InventoryHistoryResult, SteamUserError> {
305 let mut query = vec![("ajax", "1"), ("l", "english")];
306
307 let cursor = cursor.unwrap_or_default();
308 let cursor_s = cursor.s.to_string();
309 let cursor_time_frac = cursor.time_frac.to_string();
310 let cursor_time = cursor.time.to_string();
311
312 query.push(("cursor[s]", cursor_s.as_str()));
313 query.push(("cursor[time_frac]", cursor_time_frac.as_str()));
314 query.push(("cursor[time]", cursor_time.as_str()));
315
316 let http_response = self.get_path("/my/inventoryhistory/").query(&query).send().await?;
317
318 self.check_response(&http_response)?;
325
326 let status = http_response.status();
330 let body = http_response.text().await?;
331 let response: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
332 let snippet: String = body.chars().take(200).collect();
333 SteamUserError::MalformedResponse(format!("inventory history: response was not JSON (HTTP {status}): {e}; body[0..200]={snippet:?}"))
334 })?;
335
336 if !response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
337 return Err(SteamUserError::MalformedResponse("Failed to fetch inventory history".into()));
338 }
339
340 let html = response.get("html").and_then(|v| v.as_str()).unwrap_or("").replace(['\t', '\n', '\r'], "");
341
342 let descriptions = response.get("descriptions").cloned();
343
344 let next_cursor = response.get("cursor").and_then(|v| {
347 Some(InventoryCursor {
348 time: v.get("time")?.as_u64()?,
349 time_frac: u32::try_from(v.get("time_frac")?.as_u64()?).ok()?,
350 s: u32::try_from(v.get("s")?.as_u64()?).ok()?,
351 })
352 });
353
354 let steamid = self.steam_id();
355
356 let trade_history = tokio::task::spawn_blocking(move || parse_inventory_history_rows(&html, descriptions.as_ref(), steamid)).await.map_err(|e| SteamUserError::Other(format!("inventory-history parse task failed: {e}")))??;
359
360 Ok(InventoryHistoryResult { cursor: next_cursor, trade_history })
361 }
362
363 #[tracing::instrument(skip(self))]
370 pub async fn get_full_inventory_history(&self) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
371 let mut trade_history = Vec::new();
372 let mut cursor = None;
373
374 loop {
375 let result = self.get_inventory_history(cursor).await?;
376 trade_history.extend(result.trade_history);
377
378 if result.cursor.is_none() {
379 break;
380 }
381 cursor = result.cursor;
382 }
383
384 Ok(trade_history)
385 }
386
387 #[steam_endpoint(GET, host = Community, path = "/my/inventory", kind = Read)]
400 pub async fn get_active_inventories(&self) -> Result<Vec<ActiveInventory>, SteamUserError> {
401 let html = self.get_with_manual_redirects("https://steamcommunity.com/my/inventory").await?;
402
403 tokio::task::spawn_blocking(move || parse_active_inventories(&html)).await.map_err(|e| SteamUserError::Other(format!("active-inventories parse task failed: {e}")))?
405 }
406}
407
408fn parse_inventory_history_rows(html: &str, descriptions: Option<&serde_json::Value>, steamid: Option<SteamID>) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
412 use scraper::{Html, Selector};
413 let document = Html::parse_document(html);
414 let row_selector = Selector::parse(".tradehistoryrow").map_err(|e| SteamUserError::Other(e.to_string()))?;
415 let date_selector = Selector::parse(".tradehistory_date").map_err(|e| SteamUserError::Other(e.to_string()))?;
416 let timestamp_selector = Selector::parse(".tradehistory_timestamp").map_err(|e| SteamUserError::Other(e.to_string()))?;
417 let event_desc_selector = Selector::parse(".tradehistory_event_description").map_err(|e| SteamUserError::Other(e.to_string()))?;
418 let link_selector = Selector::parse("a[href]").map_err(|e| SteamUserError::Other(e.to_string()))?;
419 let plusminus_selector = Selector::parse(".tradehistory_items_plusminus").map_err(|e| SteamUserError::Other(e.to_string()))?;
420 let items_group_selector = Selector::parse(".tradehistory_items").map_err(|e| SteamUserError::Other(e.to_string()))?;
421 let item_selector = Selector::parse(".tradehistory_items_group > .history_item").map_err(|e| SteamUserError::Other(e.to_string()))?;
422
423 let mut trade_history = Vec::new();
424
425 for row in document.select(&row_selector) {
426 let date_el = row.select(&date_selector).next();
427 let timestamp_el = row.select(×tamp_selector).next();
428
429 let timestamp_text = timestamp_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
430
431 let date_text = date_el
432 .map(|e| {
433 let mut text = e.text().collect::<String>();
434 if !timestamp_text.is_empty() {
435 text = text.replace(×tamp_text, "");
436 }
437 text.trim().to_string()
438 })
439 .unwrap_or_default();
440
441 let event_desc_el = row.select(&event_desc_selector).next();
442 let raw_description = event_desc_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
443
444 let description = SteamUser::normalize_trade_description(&raw_description);
445
446 let mut trade_people = None;
447 if let Some(event_el) = event_desc_el {
448 if let Some(link_el) = event_el.select(&link_selector).next() {
449 let href = link_el.value().attr("href").unwrap_or("");
450 if href.contains("steamcommunity.com/profiles/") || href.contains("steamcommunity.com/id/") {
451 trade_people = Some(TradePeople { name: link_el.text().collect::<String>().trim().to_string(), url: href.to_string() });
452 }
453 }
454 }
455
456 let plusminus = row.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
457
458 let mut trade_history_items = Vec::new();
459
460 for items_el in row.select(&items_group_selector) {
461 let item_plusminus = items_el.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
462
463 for item_el in items_el.select(&item_selector) {
464 let text = item_el.text().collect::<String>().trim().to_string();
465 if text == "You did not receive any items in this trade." {
466 continue;
467 }
468
469 let appid = item_el.value().attr("data-appid").unwrap_or("");
470 let classid = item_el.value().attr("data-classid").unwrap_or("");
471 let instanceid = item_el.value().attr("data-instanceid").unwrap_or("0");
472 let context_id = item_el.value().attr("data-contextid").unwrap_or("");
473
474 let mut item_obj = serde_json::json!({
475 "appid": appid,
476 "classid": classid,
477 "instanceid": instanceid,
478 "contextid": context_id,
479 "steamid": steamid,
480 "plusminus": item_plusminus,
481 });
482
483 if let Some(descs) = descriptions {
484 if let Some(asset_desc) = descs.get(appid).and_then(|a| a.get(format!("{}_{}", classid, instanceid))) {
485 if let Some(obj) = item_obj.as_object_mut() {
486 if let Some(desc_obj) = asset_desc.as_object() {
487 for (k, v) in desc_obj {
488 obj.insert(k.clone(), v.clone());
489 }
490 }
491 }
492 }
493 }
494 trade_history_items.push(item_obj);
495 }
496 }
497
498 if !trade_history_items.is_empty() {
499 let timestamp_str = format!("{} {}", date_text, timestamp_text);
500 let timestamp = SteamUser::parse_inventory_history_date(&date_text, ×tamp_text);
501
502 let items_key: String = trade_history_items
503 .iter()
504 .map(|item| {
505 let classid = item.get("classid").and_then(|v| v.as_str()).unwrap_or("0");
506 let instanceid = item.get("instanceid").and_then(|v| v.as_str()).unwrap_or("0");
507 format!("{}_{}", classid, instanceid)
508 })
509 .collect::<Vec<_>>()
510 .join("|");
511 let id_raw = format!("{}_{}_{}_{}", timestamp, description, plusminus, items_key,);
512 let id: String = id_raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '+' | '_' | '|')).collect::<String>().to_lowercase();
513
514 trade_history.push(InventoryHistoryItem { id, timestamp, timestamp_str, description, plusminus, trade_history_items, steamid, trade_people });
515 }
516 }
517
518 Ok(trade_history)
519}
520
521fn parse_active_inventories(html: &str) -> Result<Vec<ActiveInventory>, SteamUserError> {
526 use scraper::{Html, Selector};
527 let document = Html::parse_document(html);
528
529 let games_list_selector = Selector::parse("#games_list_public").map_err(|e| SteamUserError::Other(e.to_string()))?;
530
531 let Some(games_list) = document.select(&games_list_selector).next() else {
532 return Ok(Vec::new());
534 };
535
536 let tab_selector = Selector::parse(".games_list_tabs > .games_list_tab").map_err(|e| SteamUserError::Other(e.to_string()))?;
537 let icon_selector = Selector::parse(".item_desc_game_icon img").map_err(|e| SteamUserError::Other(e.to_string()))?;
538 let name_selector = Selector::parse(".games_list_tab_name").map_err(|e| SteamUserError::Other(e.to_string()))?;
539 let count_selector = Selector::parse(".games_list_tab_number").map_err(|e| SteamUserError::Other(e.to_string()))?;
540
541 let mut inventories = Vec::new();
542
543 for tab in games_list.select(&tab_selector) {
544 let game_icon = tab.select(&icon_selector).next().and_then(|el| el.value().attr("src")).map(|s| s.to_string());
545
546 let game_name = tab.select(&name_selector).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
547
548 let count = tab
549 .select(&count_selector)
550 .next()
551 .map(|el| {
552 let text = el.text().collect::<String>();
553 let text = text.trim();
554 text.trim_start_matches('(').trim_end_matches(')').parse::<u32>().unwrap_or(0)
555 })
556 .unwrap_or(0);
557
558 let app_id = tab.value().attr("id").and_then(|id| id.strip_prefix("inventory_link_")).and_then(|id| id.parse::<u32>().ok()).unwrap_or(0);
559
560 if app_id > 0 {
561 inventories.push(ActiveInventory { app_id, game_icon, game_name, count });
562 }
563 }
564
565 Ok(inventories)
566}