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 response: serde_json::Value = self.get_path("/my/inventoryhistory/").query(&query).send().await?.json().await?;
317
318 if !response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
319 return Err(SteamUserError::MalformedResponse("Failed to fetch inventory history".into()));
320 }
321
322 let html = response.get("html").and_then(|v| v.as_str()).unwrap_or("").replace(['\t', '\n', '\r'], "");
323
324 let descriptions = response.get("descriptions").cloned();
325
326 let next_cursor = response.get("cursor").and_then(|v| {
329 Some(InventoryCursor {
330 time: v.get("time")?.as_u64()?,
331 time_frac: u32::try_from(v.get("time_frac")?.as_u64()?).ok()?,
332 s: u32::try_from(v.get("s")?.as_u64()?).ok()?,
333 })
334 });
335
336 let steamid = self.steam_id();
337
338 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}")))??;
341
342 Ok(InventoryHistoryResult { cursor: next_cursor, trade_history })
343 }
344
345 #[tracing::instrument(skip(self))]
352 pub async fn get_full_inventory_history(&self) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
353 let mut trade_history = Vec::new();
354 let mut cursor = None;
355
356 loop {
357 let result = self.get_inventory_history(cursor).await?;
358 trade_history.extend(result.trade_history);
359
360 if result.cursor.is_none() {
361 break;
362 }
363 cursor = result.cursor;
364 }
365
366 Ok(trade_history)
367 }
368
369 #[steam_endpoint(GET, host = Community, path = "/my/inventory", kind = Read)]
382 pub async fn get_active_inventories(&self) -> Result<Vec<ActiveInventory>, SteamUserError> {
383 let html = self.get_with_manual_redirects("https://steamcommunity.com/my/inventory").await?;
384
385 tokio::task::spawn_blocking(move || parse_active_inventories(&html)).await.map_err(|e| SteamUserError::Other(format!("active-inventories parse task failed: {e}")))?
387 }
388}
389
390fn parse_inventory_history_rows(html: &str, descriptions: Option<&serde_json::Value>, steamid: Option<SteamID>) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
394 use scraper::{Html, Selector};
395 let document = Html::parse_document(html);
396 let row_selector = Selector::parse(".tradehistoryrow").map_err(|e| SteamUserError::Other(e.to_string()))?;
397 let date_selector = Selector::parse(".tradehistory_date").map_err(|e| SteamUserError::Other(e.to_string()))?;
398 let timestamp_selector = Selector::parse(".tradehistory_timestamp").map_err(|e| SteamUserError::Other(e.to_string()))?;
399 let event_desc_selector = Selector::parse(".tradehistory_event_description").map_err(|e| SteamUserError::Other(e.to_string()))?;
400 let link_selector = Selector::parse("a[href]").map_err(|e| SteamUserError::Other(e.to_string()))?;
401 let plusminus_selector = Selector::parse(".tradehistory_items_plusminus").map_err(|e| SteamUserError::Other(e.to_string()))?;
402 let items_group_selector = Selector::parse(".tradehistory_items").map_err(|e| SteamUserError::Other(e.to_string()))?;
403 let item_selector = Selector::parse(".tradehistory_items_group > .history_item").map_err(|e| SteamUserError::Other(e.to_string()))?;
404
405 let mut trade_history = Vec::new();
406
407 for row in document.select(&row_selector) {
408 let date_el = row.select(&date_selector).next();
409 let timestamp_el = row.select(×tamp_selector).next();
410
411 let timestamp_text = timestamp_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
412
413 let date_text = date_el
414 .map(|e| {
415 let mut text = e.text().collect::<String>();
416 if !timestamp_text.is_empty() {
417 text = text.replace(×tamp_text, "");
418 }
419 text.trim().to_string()
420 })
421 .unwrap_or_default();
422
423 let event_desc_el = row.select(&event_desc_selector).next();
424 let raw_description = event_desc_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
425
426 let description = SteamUser::normalize_trade_description(&raw_description);
427
428 let mut trade_people = None;
429 if let Some(event_el) = event_desc_el {
430 if let Some(link_el) = event_el.select(&link_selector).next() {
431 let href = link_el.value().attr("href").unwrap_or("");
432 if href.contains("steamcommunity.com/profiles/") || href.contains("steamcommunity.com/id/") {
433 trade_people = Some(TradePeople { name: link_el.text().collect::<String>().trim().to_string(), url: href.to_string() });
434 }
435 }
436 }
437
438 let plusminus = row.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
439
440 let mut trade_history_items = Vec::new();
441
442 for items_el in row.select(&items_group_selector) {
443 let item_plusminus = items_el.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
444
445 for item_el in items_el.select(&item_selector) {
446 let text = item_el.text().collect::<String>().trim().to_string();
447 if text == "You did not receive any items in this trade." {
448 continue;
449 }
450
451 let appid = item_el.value().attr("data-appid").unwrap_or("");
452 let classid = item_el.value().attr("data-classid").unwrap_or("");
453 let instanceid = item_el.value().attr("data-instanceid").unwrap_or("0");
454 let context_id = item_el.value().attr("data-contextid").unwrap_or("");
455
456 let mut item_obj = serde_json::json!({
457 "appid": appid,
458 "classid": classid,
459 "instanceid": instanceid,
460 "contextid": context_id,
461 "steamid": steamid,
462 "plusminus": item_plusminus,
463 });
464
465 if let Some(descs) = descriptions {
466 if let Some(asset_desc) = descs.get(appid).and_then(|a| a.get(format!("{}_{}", classid, instanceid))) {
467 if let Some(obj) = item_obj.as_object_mut() {
468 if let Some(desc_obj) = asset_desc.as_object() {
469 for (k, v) in desc_obj {
470 obj.insert(k.clone(), v.clone());
471 }
472 }
473 }
474 }
475 }
476 trade_history_items.push(item_obj);
477 }
478 }
479
480 if !trade_history_items.is_empty() {
481 let timestamp_str = format!("{} {}", date_text, timestamp_text);
482 let timestamp = SteamUser::parse_inventory_history_date(&date_text, ×tamp_text);
483
484 let items_key: String = trade_history_items
485 .iter()
486 .map(|item| {
487 let classid = item.get("classid").and_then(|v| v.as_str()).unwrap_or("0");
488 let instanceid = item.get("instanceid").and_then(|v| v.as_str()).unwrap_or("0");
489 format!("{}_{}", classid, instanceid)
490 })
491 .collect::<Vec<_>>()
492 .join("|");
493 let id_raw = format!("{}_{}_{}_{}", timestamp, description, plusminus, items_key,);
494 let id: String = id_raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '+' | '_' | '|')).collect::<String>().to_lowercase();
495
496 trade_history.push(InventoryHistoryItem { id, timestamp, timestamp_str, description, plusminus, trade_history_items, steamid, trade_people });
497 }
498 }
499
500 Ok(trade_history)
501}
502
503fn parse_active_inventories(html: &str) -> Result<Vec<ActiveInventory>, SteamUserError> {
508 use scraper::{Html, Selector};
509 let document = Html::parse_document(html);
510
511 let games_list_selector = Selector::parse("#games_list_public").map_err(|e| SteamUserError::Other(e.to_string()))?;
512
513 let Some(games_list) = document.select(&games_list_selector).next() else {
514 return Ok(Vec::new());
516 };
517
518 let tab_selector = Selector::parse(".games_list_tabs > .games_list_tab").map_err(|e| SteamUserError::Other(e.to_string()))?;
519 let icon_selector = Selector::parse(".item_desc_game_icon img").map_err(|e| SteamUserError::Other(e.to_string()))?;
520 let name_selector = Selector::parse(".games_list_tab_name").map_err(|e| SteamUserError::Other(e.to_string()))?;
521 let count_selector = Selector::parse(".games_list_tab_number").map_err(|e| SteamUserError::Other(e.to_string()))?;
522
523 let mut inventories = Vec::new();
524
525 for tab in games_list.select(&tab_selector) {
526 let game_icon = tab.select(&icon_selector).next().and_then(|el| el.value().attr("src")).map(|s| s.to_string());
527
528 let game_name = tab.select(&name_selector).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
529
530 let count = tab
531 .select(&count_selector)
532 .next()
533 .map(|el| {
534 let text = el.text().collect::<String>();
535 let text = text.trim();
536 text.trim_start_matches('(').trim_end_matches(')').parse::<u32>().unwrap_or(0)
537 })
538 .unwrap_or(0);
539
540 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);
541
542 if app_id > 0 {
543 inventories.push(ActiveInventory { app_id, game_icon, game_name, count });
544 }
545 }
546
547 Ok(inventories)
548}