use steamid::SteamID;
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{ActiveInventory, AppId, ContextId, EconItem, InventoryCursor, InventoryHistoryItem, InventoryHistoryResult, InventoryResponse, PriceOverview, TradePeople},
};
impl SteamUser {
#[steam_endpoint(GET, host = Community, path = "/inventory/{user_id}/{app_id}/{context_id}", kind = Read)]
pub async fn get_user_inventory_contents(&self, user_id: SteamID, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
let path = format!("/inventory/{}/{}/{}", user_id.steam_id64(), appid, context_id);
let mut items = Vec::new();
let mut start_assetid = None;
loop {
let mut request = self.get_path(&path).query(&[("count", "2000"), ("preserve_bbcode", "1"), ("raw_asset_properties", "1")]);
if let Some(start) = start_assetid.as_ref() {
request = request.query(&[("start_assetid", start)]);
}
let response: InventoryResponse = request.send().await?.error_for_status()?.json().await?;
tracing::info!(
path = %path,
success = response.success,
total_count = ?response.total_inventory_count,
assets = response.assets.len(),
descriptions = response.descriptions.len(),
"inventory fetch result",
);
if response.success != 1 {
tracing::warn!(success = response.success, "inventory fetch: Steam returned non-1 success, returning error");
return Err(SteamUserError::from_eresult(response.success));
}
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();
for asset in &response.assets {
let key = format!("{}_{}", asset.classid, asset.instanceid);
if let Some(desc) = desc_map.get(&key) {
match EconItem::try_from_inventory_data(asset, desc.clone()) {
Ok(mut item) => {
if let Some(steam_id) = self.steam_id() {
item.owner_steam_id = Some(steam_id);
}
items.push(item);
}
Err(e) => {
tracing::warn!(assetid = %asset.assetid, classid = %asset.classid, error = %e, "skipping malformed inventory asset");
}
}
}
}
if response.more_items {
if let Some(last) = response.last_assetid {
start_assetid = Some(last);
continue;
}
}
break;
}
Ok(items)
}
#[tracing::instrument(skip(self), fields(app_id = appid.get(), context_id = context_id.get()))]
pub async fn get_inventory(&self, appid: AppId, context_id: ContextId) -> Result<Vec<EconItem>, SteamUserError> {
let steam_id = self.steam_id().ok_or(SteamUserError::NotLoggedIn)?;
self.get_user_inventory_contents(steam_id, appid, context_id).await
}
#[steam_endpoint(GET, host = Community, path = "/my/inventory/json/{app_id}/{context_id}", kind = Read)]
pub async fn get_inventory_trading(&self, appid: AppId, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
let response: serde_json::Value = self.get_path(format!("/my/inventory/json/{}/{}", appid, context_id)).query(&[("trading", "1")]).send().await?.json().await?;
if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
let mut enriched = response;
let steam_id = self.steam_id().map(|id| id.steam_id64().to_string()).unwrap_or_default();
let context_str = context_id.to_string();
if let Some(obj) = enriched.as_object_mut() {
if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
for (_, item) in rg_inv {
if let Some(item_obj) = item.as_object_mut() {
item_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
}
}
}
if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
for (_, desc) in rg_desc {
if let Some(desc_obj) = desc.as_object_mut() {
desc_obj.insert("steamId".to_string(), serde_json::json!(steam_id));
desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
}
}
}
}
Ok(enriched)
} else {
Err(SteamUserError::MalformedResponse("Failed to fetch trading inventory".into()))
}
}
#[steam_endpoint(GET, host = Community, path = "/market/priceoverview/", kind = Read)]
pub async fn get_price_overview(&self, appid: AppId, market_hash_name: &str) -> Result<PriceOverview, SteamUserError> {
let appid_str = appid.to_string();
let response: PriceOverview = self
.get_path("/market/priceoverview/")
.query(&[
("appid", &appid_str),
("market_hash_name", &market_hash_name.to_string()),
("currency", &"15".to_string()), ])
.send()
.await?
.json()
.await?;
Ok(response)
}
fn normalize_trade_description(description: &str) -> String {
const DESCRIPTION_LIST: &[&str] = &[
"You purchased an item on the Community Market.",
"You listed an item on the Community Market.",
"You canceled a listing on the Community Market. The item was returned to you.",
"Crafted",
"Expired",
"Earned a new rank and got a drop",
"Got an item drop",
"Random item drop",
"Purchased a gift",
"Earned by redeeming Steam Points",
"Earned by completing your Store Discovery Queue",
"Earned",
"Traded",
"Earned due to game play time",
"Listed on the Steam Community Market",
"Turned into Gems",
"Unpacked a booster pack",
"Purchased with Gems",
"Unpacked Gems from Sack",
"Earned by crafting",
"Used",
"Unsealed",
"Earned by sale purchases",
"Unlocked a container",
"Purchased from the store",
"You deleted",
"Found",
"Received from the Community Market",
"Exchanged one or more items for something different",
"Earned an item due to ownership of another game",
"Sticker applied",
"Sticker removed",
"Subscription/Seasonal Item Grant",
"Earned from unlocking an achievement",
"Moved to Storage Unit",
];
if DESCRIPTION_LIST.contains(&description) {
return description.to_string();
}
if description.starts_with("You traded with ") {
return "You traded with".to_string();
}
if description.starts_with("Gift sent to and redeemed by ") {
return "Gift sent to and redeemed by".to_string();
}
if description.starts_with("Your trade with ") && description.ends_with(" was on hold, and the trade has now completed.") {
return "Your trade with friend was on hold, and the trade has now completed.".to_string();
}
if description.starts_with("You listed an item on the Community Market. The listing was placed on hold until") {
return "You listed an item on the Community Market. The listing was placed on hold until".to_string();
}
if description.starts_with("Earned in ") {
return "Earned in game".to_string();
}
if description.starts_with("Refunded a gift because the recipient,") && description.ends_with("declined") {
return "Refunded a gift because the recipient declined".to_string();
}
if description.starts_with("Your held trade with") && description.ends_with("was canceled. The items have been returned to you.") {
return "Your held trade with person was canceled. The items have been returned to you.".to_string();
}
description.to_string()
}
#[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/partnerinventory/", kind = Read)]
pub async fn get_inventory_trading_partner(&self, appid: AppId, partner: SteamID, context_id: ContextId) -> Result<serde_json::Value, SteamUserError> {
let appid_str = appid.to_string();
let partner_str = partner.steam_id64().to_string();
let context_str = context_id.to_string();
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?;
if response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
let mut enriched = response;
let partner_str = partner.steam_id64().to_string();
let context_str = context_id.to_string();
if let Some(obj) = enriched.as_object_mut() {
if let Some(rg_inv) = obj.get_mut("rgInventory").and_then(|v| v.as_object_mut()) {
for (_, item) in rg_inv {
if let Some(item_obj) = item.as_object_mut() {
item_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
item_obj.insert("contextId".to_string(), serde_json::json!(context_str));
}
}
}
if let Some(rg_desc) = obj.get_mut("rgDescriptions").and_then(|v| v.as_object_mut()) {
for (_, desc) in rg_desc {
if let Some(desc_obj) = desc.as_object_mut() {
desc_obj.insert("steamId".to_string(), serde_json::json!(partner_str));
desc_obj.insert("contextId".to_string(), serde_json::json!(context_str));
}
}
}
}
Ok(enriched)
} else {
Err(SteamUserError::MalformedResponse("Failed to fetch trading partner inventory".into()))
}
}
fn parse_inventory_history_date(date_text: &str, time_text: &str) -> u64 {
use chrono::NaiveDateTime;
let combined = format!("{} {}", date_text, time_text);
if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M%P") {
return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
}
if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %l:%M %P") {
return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
}
if let Ok(dt) = NaiveDateTime::parse_from_str(&combined, "%d %b, %Y %H:%M") {
return u64::try_from(dt.and_utc().timestamp()).unwrap_or(0);
}
tracing::warn!(combined = %combined, "failed to parse inventory history date");
0
}
#[steam_endpoint(GET, host = Community, path = "/my/inventoryhistory/", kind = Read)]
pub async fn get_inventory_history(&self, cursor: Option<InventoryCursor>) -> Result<InventoryHistoryResult, SteamUserError> {
let mut query = vec![("ajax", "1"), ("l", "english")];
let cursor = cursor.unwrap_or_default();
let cursor_s = cursor.s.to_string();
let cursor_time_frac = cursor.time_frac.to_string();
let cursor_time = cursor.time.to_string();
query.push(("cursor[s]", cursor_s.as_str()));
query.push(("cursor[time_frac]", cursor_time_frac.as_str()));
query.push(("cursor[time]", cursor_time.as_str()));
let response: serde_json::Value = self.get_path("/my/inventoryhistory/").query(&query).send().await?.json().await?;
if !response.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false) {
return Err(SteamUserError::MalformedResponse("Failed to fetch inventory history".into()));
}
let html = response.get("html").and_then(|v| v.as_str()).unwrap_or("").replace(['\t', '\n', '\r'], "");
let descriptions = response.get("descriptions").cloned();
let next_cursor = response.get("cursor").and_then(|v| {
Some(InventoryCursor {
time: v.get("time")?.as_u64()?,
time_frac: u32::try_from(v.get("time_frac")?.as_u64()?).ok()?,
s: u32::try_from(v.get("s")?.as_u64()?).ok()?,
})
});
let steamid = self.steam_id();
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}")))??;
Ok(InventoryHistoryResult { cursor: next_cursor, trade_history })
}
#[tracing::instrument(skip(self))]
pub async fn get_full_inventory_history(&self) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
let mut trade_history = Vec::new();
let mut cursor = None;
loop {
let result = self.get_inventory_history(cursor).await?;
trade_history.extend(result.trade_history);
if result.cursor.is_none() {
break;
}
cursor = result.cursor;
}
Ok(trade_history)
}
#[steam_endpoint(GET, host = Community, path = "/my/inventory", kind = Read)]
pub async fn get_active_inventories(&self) -> Result<Vec<ActiveInventory>, SteamUserError> {
let html = self.get_with_manual_redirects("https://steamcommunity.com/my/inventory").await?;
tokio::task::spawn_blocking(move || parse_active_inventories(&html)).await.map_err(|e| SteamUserError::Other(format!("active-inventories parse task failed: {e}")))?
}
}
fn parse_inventory_history_rows(html: &str, descriptions: Option<&serde_json::Value>, steamid: Option<SteamID>) -> Result<Vec<InventoryHistoryItem>, SteamUserError> {
use scraper::{Html, Selector};
let document = Html::parse_document(html);
let row_selector = Selector::parse(".tradehistoryrow").map_err(|e| SteamUserError::Other(e.to_string()))?;
let date_selector = Selector::parse(".tradehistory_date").map_err(|e| SteamUserError::Other(e.to_string()))?;
let timestamp_selector = Selector::parse(".tradehistory_timestamp").map_err(|e| SteamUserError::Other(e.to_string()))?;
let event_desc_selector = Selector::parse(".tradehistory_event_description").map_err(|e| SteamUserError::Other(e.to_string()))?;
let link_selector = Selector::parse("a[href]").map_err(|e| SteamUserError::Other(e.to_string()))?;
let plusminus_selector = Selector::parse(".tradehistory_items_plusminus").map_err(|e| SteamUserError::Other(e.to_string()))?;
let items_group_selector = Selector::parse(".tradehistory_items").map_err(|e| SteamUserError::Other(e.to_string()))?;
let item_selector = Selector::parse(".tradehistory_items_group > .history_item").map_err(|e| SteamUserError::Other(e.to_string()))?;
let mut trade_history = Vec::new();
for row in document.select(&row_selector) {
let date_el = row.select(&date_selector).next();
let timestamp_el = row.select(×tamp_selector).next();
let timestamp_text = timestamp_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let date_text = date_el
.map(|e| {
let mut text = e.text().collect::<String>();
if !timestamp_text.is_empty() {
text = text.replace(×tamp_text, "");
}
text.trim().to_string()
})
.unwrap_or_default();
let event_desc_el = row.select(&event_desc_selector).next();
let raw_description = event_desc_el.map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let description = SteamUser::normalize_trade_description(&raw_description);
let mut trade_people = None;
if let Some(event_el) = event_desc_el {
if let Some(link_el) = event_el.select(&link_selector).next() {
let href = link_el.value().attr("href").unwrap_or("");
if href.contains("steamcommunity.com/profiles/") || href.contains("steamcommunity.com/id/") {
trade_people = Some(TradePeople { name: link_el.text().collect::<String>().trim().to_string(), url: href.to_string() });
}
}
}
let plusminus = row.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let mut trade_history_items = Vec::new();
for items_el in row.select(&items_group_selector) {
let item_plusminus = items_el.select(&plusminus_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
for item_el in items_el.select(&item_selector) {
let text = item_el.text().collect::<String>().trim().to_string();
if text == "You did not receive any items in this trade." {
continue;
}
let appid = item_el.value().attr("data-appid").unwrap_or("");
let classid = item_el.value().attr("data-classid").unwrap_or("");
let instanceid = item_el.value().attr("data-instanceid").unwrap_or("0");
let context_id = item_el.value().attr("data-contextid").unwrap_or("");
let mut item_obj = serde_json::json!({
"appid": appid,
"classid": classid,
"instanceid": instanceid,
"contextid": context_id,
"steamid": steamid,
"plusminus": item_plusminus,
});
if let Some(descs) = descriptions {
if let Some(asset_desc) = descs.get(appid).and_then(|a| a.get(format!("{}_{}", classid, instanceid))) {
if let Some(obj) = item_obj.as_object_mut() {
if let Some(desc_obj) = asset_desc.as_object() {
for (k, v) in desc_obj {
obj.insert(k.clone(), v.clone());
}
}
}
}
}
trade_history_items.push(item_obj);
}
}
if !trade_history_items.is_empty() {
let timestamp_str = format!("{} {}", date_text, timestamp_text);
let timestamp = SteamUser::parse_inventory_history_date(&date_text, ×tamp_text);
let items_key: String = trade_history_items
.iter()
.map(|item| {
let classid = item.get("classid").and_then(|v| v.as_str()).unwrap_or("0");
let instanceid = item.get("instanceid").and_then(|v| v.as_str()).unwrap_or("0");
format!("{}_{}", classid, instanceid)
})
.collect::<Vec<_>>()
.join("|");
let id_raw = format!("{}_{}_{}_{}", timestamp, description, plusminus, items_key,);
let id: String = id_raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '+' | '_' | '|')).collect::<String>().to_lowercase();
trade_history.push(InventoryHistoryItem { id, timestamp, timestamp_str, description, plusminus, trade_history_items, steamid, trade_people });
}
}
Ok(trade_history)
}
fn parse_active_inventories(html: &str) -> Result<Vec<ActiveInventory>, SteamUserError> {
use scraper::{Html, Selector};
let document = Html::parse_document(html);
let games_list_selector = Selector::parse("#games_list_public").map_err(|e| SteamUserError::Other(e.to_string()))?;
let Some(games_list) = document.select(&games_list_selector).next() else {
return Ok(Vec::new());
};
let tab_selector = Selector::parse(".games_list_tabs > .games_list_tab").map_err(|e| SteamUserError::Other(e.to_string()))?;
let icon_selector = Selector::parse(".item_desc_game_icon img").map_err(|e| SteamUserError::Other(e.to_string()))?;
let name_selector = Selector::parse(".games_list_tab_name").map_err(|e| SteamUserError::Other(e.to_string()))?;
let count_selector = Selector::parse(".games_list_tab_number").map_err(|e| SteamUserError::Other(e.to_string()))?;
let mut inventories = Vec::new();
for tab in games_list.select(&tab_selector) {
let game_icon = tab.select(&icon_selector).next().and_then(|el| el.value().attr("src")).map(|s| s.to_string());
let game_name = tab.select(&name_selector).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
let count = tab
.select(&count_selector)
.next()
.map(|el| {
let text = el.text().collect::<String>();
let text = text.trim();
text.trim_start_matches('(').trim_end_matches(')').parse::<u32>().unwrap_or(0)
})
.unwrap_or(0);
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);
if app_id > 0 {
inventories.push(ActiveInventory { app_id, game_icon, game_name, count });
}
}
Ok(inventories)
}