use std::sync::OnceLock;
use chrono::{FixedOffset, TimeZone};
use regex::Regex;
use scraper::{Html, Selector};
use serde_json::json;
use steamid::SteamID;
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{ParsedTradeURL, TradeOffer, TradeOfferAsset, TradeOfferItem, TradeOfferItems, TradeOfferPartner, TradeOfferResult, TradeOfferStatus, TradeOfferSummary, TradeOffersResponse},
};
static SEL_TRADE_URL: OnceLock<Selector> = OnceLock::new();
fn sel_trade_url() -> &'static Selector {
SEL_TRADE_URL.get_or_init(|| Selector::parse("#trade_offer_access_url").expect("valid CSS selector"))
}
static SEL_SUBPAGE: OnceLock<Selector> = OnceLock::new();
fn sel_subpage() -> &'static Selector {
SEL_SUBPAGE.get_or_init(|| Selector::parse(".profile_subpage_selector > a").expect("valid CSS selector"))
}
static SEL_ESCROW: OnceLock<Selector> = OnceLock::new();
fn sel_escrow() -> &'static Selector {
SEL_ESCROW.get_or_init(|| Selector::parse(".trade_offers_escrow_explanation > .title").expect("valid CSS selector"))
}
static SEL_TRADEOFFER: OnceLock<Selector> = OnceLock::new();
fn sel_tradeoffer() -> &'static Selector {
SEL_TRADEOFFER.get_or_init(|| Selector::parse(".profile_leftcol > .tradeoffer").expect("valid CSS selector"))
}
static SEL_PLAYER_AVATAR: OnceLock<Selector> = OnceLock::new();
fn sel_player_avatar() -> &'static Selector {
SEL_PLAYER_AVATAR.get_or_init(|| Selector::parse(".playerAvatar").expect("valid CSS selector"))
}
static SEL_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_img() -> &'static Selector {
SEL_IMG.get_or_init(|| Selector::parse("img").expect("valid CSS selector"))
}
static SEL_A: OnceLock<Selector> = OnceLock::new();
fn sel_a() -> &'static Selector {
SEL_A.get_or_init(|| Selector::parse("a").expect("valid CSS selector"))
}
static SEL_TRADEOFFER_HEADER: OnceLock<Selector> = OnceLock::new();
fn sel_tradeoffer_header() -> &'static Selector {
SEL_TRADEOFFER_HEADER.get_or_init(|| Selector::parse(".tradeoffer_header").expect("valid CSS selector"))
}
static SEL_ITEMS_CTN: OnceLock<Selector> = OnceLock::new();
fn sel_items_ctn() -> &'static Selector {
SEL_ITEMS_CTN.get_or_init(|| Selector::parse(".tradeoffer_items_ctn").expect("valid CSS selector"))
}
static SEL_PRIMARY_ITEMS: OnceLock<Selector> = OnceLock::new();
fn sel_primary_items() -> &'static Selector {
SEL_PRIMARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.primary").expect("valid CSS selector"))
}
static SEL_SECONDARY_ITEMS: OnceLock<Selector> = OnceLock::new();
fn sel_secondary_items() -> &'static Selector {
SEL_SECONDARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.secondary").expect("valid CSS selector"))
}
static SEL_AVATAR_LINK: OnceLock<Selector> = OnceLock::new();
fn sel_avatar_link() -> &'static Selector {
SEL_AVATAR_LINK.get_or_init(|| Selector::parse("a.tradeoffer_avatar").expect("valid CSS selector"))
}
static SEL_TRADE_ITEM: OnceLock<Selector> = OnceLock::new();
fn sel_trade_item() -> &'static Selector {
SEL_TRADE_ITEM.get_or_init(|| Selector::parse(".tradeoffer_item_list > .trade_item").expect("valid CSS selector"))
}
static SEL_ITEMS_BANNER: OnceLock<Selector> = OnceLock::new();
fn sel_items_banner() -> &'static Selector {
SEL_ITEMS_BANNER.get_or_init(|| Selector::parse(".tradeoffer_items_banner").expect("valid CSS selector"))
}
static RE_HTML_TAGS: OnceLock<Regex> = OnceLock::new();
fn re_html_tags() -> &'static Regex {
RE_HTML_TAGS.get_or_init(|| Regex::new(r"<[^>]+>").expect("valid regex"))
}
impl SteamUser {
#[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/privacy", kind = Read)]
pub async fn get_trade_url(&self) -> Result<Option<String>, SteamUserError> {
let response = self.get_path("/my/tradeoffers/privacy").send().await?.text().await?;
let html = Html::parse_document(&response);
Ok(html.select(sel_trade_url()).next().and_then(|el| el.value().attr("value").map(|v| v.to_string())))
}
#[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/", kind = Read)]
pub async fn get_trade_offer(&self) -> Result<TradeOffersResponse, SteamUserError> {
let response = self.get_with_manual_redirects("https://steamcommunity.com/my/tradeoffers/").await?;
let html = Html::parse_document(&response);
let mut incoming_offers = TradeOfferSummary { link: String::new(), count: 0 };
let mut sent_offers = TradeOfferSummary { link: String::new(), count: 0 };
let mut trade_hold_count = 0;
for el in html.select(sel_subpage()) {
let text = el.text().collect::<String>();
let link = el.value().attr("href").unwrap_or("").trim_end_matches('/').to_string();
let mut count = 0;
if let (Some(start), Some(end)) = (text.find('('), text.find(')')) {
count = text[start + 1..end].trim().parse().unwrap_or(0);
}
if link.ends_with("tradeoffers") {
incoming_offers = TradeOfferSummary { link, count };
} else if link.ends_with("tradeoffers/sent") {
sent_offers = TradeOfferSummary { link, count };
}
}
if let Some(el) = html.select(sel_escrow()).next() {
let text = el.text().collect::<String>().trim().to_string();
if text.ends_with("trade on hold") || text.ends_with("trades on hold") {
trade_hold_count = text.split(' ').next().and_then(|s| s.parse().ok()).unwrap_or(0);
}
}
let mut trade_offers = Vec::new();
for el in html.select(sel_tradeoffer()) {
let id_str = el.value().attr("id").unwrap_or("");
if !id_str.starts_with("tradeofferid_") {
continue;
}
let tradeofferid = id_str["tradeofferid_".len()..].parse().unwrap_or(0);
if tradeofferid == 0 {
continue;
}
let html_content = el.html();
let partner_data = if let Some(start) = html_content.find("ReportTradeScam(") {
let rest = &html_content[start + "ReportTradeScam(".len()..];
if let Some(end) = rest.find(");") {
let part = rest[..end].trim();
let parts: Vec<String> = part
.split(',')
.map(|s| {
let decoded = s.trim().replace(""", "\"").replace("&", "&").replace("<", "<").replace(">", ">").replace("'", "'");
decoded.trim_matches(|c: char| c == '\'' || c == '"').trim().to_string()
})
.collect();
if parts.len() >= 2 {
let steam_id = parts[0].parse::<SteamID>().ok();
let name = decode_js_unicode_escapes(&parts[1]);
Some((steam_id, name))
} else {
None
}
} else {
None
}
} else {
None
};
let (partner_steam_id, partner_name) = partner_data.unwrap_or_default();
let player_avatar = el.select(sel_player_avatar()).next();
let avatar_url = player_avatar.and_then(|a| a.select(sel_img()).next()).and_then(|i| i.value().attr("src")).unwrap_or("");
let avatar_hash = if let Some(pos) = avatar_url.rfind('/') {
let filename = &avatar_url[pos + 1..];
if let Some(dot_pos) = filename.find('.') {
filename[..dot_pos].to_string()
} else {
filename.to_string()
}
} else {
String::new()
};
let link = player_avatar.and_then(|a| a.select(sel_a()).next()).and_then(|l| l.value().attr("href")).unwrap_or("").to_string();
let header = el.select(sel_tradeoffer_header()).next().map(|h| h.text().collect::<String>().trim().to_string()).unwrap_or_default();
let active = el.select(sel_items_ctn()).next().map(|c| c.value().has_class("active", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false);
let primary_items_ctn = el.select(sel_primary_items()).next();
let secondary_items_ctn = el.select(sel_secondary_items()).next();
let primary_steam_id = primary_items_ctn.and_then(|c| c.select(sel_avatar_link()).next()).and_then(|a| a.value().attr("data-miniprofile")).and_then(|m| m.parse::<u32>().ok()).map(SteamID::from_individual_account_id);
let secondary_steam_id = secondary_items_ctn.and_then(|c| c.select(sel_avatar_link()).next()).and_then(|a| a.value().attr("data-miniprofile")).and_then(|m| m.parse::<u32>().ok()).map(SteamID::from_individual_account_id);
let parse_items = |ctn: Option<scraper::ElementRef>| {
let mut items = Vec::new();
if let Some(ctn) = ctn {
for item_el in ctn.select(sel_trade_item()) {
let economy_item = item_el.value().attr("data-economy-item").unwrap_or("").to_string();
let img = item_el.select(sel_img()).next().and_then(|i| i.value().attr("src")).unwrap_or("").to_string();
let img_hi = item_el
.select(sel_img())
.next()
.and_then(|i| i.value().attr("srcset"))
.and_then(|srcset| {
srcset.split(',').find_map(|entry| {
let entry = entry.trim();
if entry.ends_with("2x") {
Some(entry.trim_end_matches("2x").trim().to_string())
} else {
None
}
})
})
.unwrap_or_default();
let missing = item_el.value().has_class("missing", scraper::CaseSensitivity::AsciiCaseInsensitive);
items.push(TradeOfferItem { economy_item, img, img_hi, missing });
}
}
items
};
let banner_el = el.select(sel_items_banner()).next();
let banner_text = banner_el.map(|b| b.text().collect::<String>().trim().to_string()).unwrap_or_default();
let status = if active {
TradeOfferStatus::Active
} else if banner_el.map(|b| b.value().has_class("accepted", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false) {
TradeOfferStatus::Accepted
} else if banner_text.contains("Unavailable") || banner_text.contains("unavailable") {
TradeOfferStatus::Unavailable
} else if banner_text.contains("Counter") || banner_text.contains("counter") {
TradeOfferStatus::Countered
} else {
TradeOfferStatus::Inactive
};
let banner_timestamp = parse_steam_banner_timestamp(&banner_text);
trade_offers.push(TradeOffer {
tradeofferid,
partner: TradeOfferPartner { steamid: partner_steam_id, name: partner_name, avatar_hash, link, header },
active,
primary: TradeOfferItems { steamid: primary_steam_id, items: parse_items(primary_items_ctn) },
secondary: TradeOfferItems { steamid: secondary_steam_id, items: parse_items(secondary_items_ctn) },
banner_text,
banner_timestamp,
status,
});
}
Ok(TradeOffersResponse { incoming_offers, sent_offers, trade_hold_count, trade_offers })
}
#[steam_endpoint(POST, host = Community, path = "/tradeoffer/new/send", kind = Write)]
pub async fn send_trade_offer(&self, trade_url: &str, my_assets: Vec<TradeOfferAsset>, their_assets: Vec<TradeOfferAsset>, message: &str) -> Result<TradeOfferResult, SteamUserError> {
let parsed = self.parse_trade_url(trade_url).ok_or_else(|| SteamUserError::Other("Invalid trade URL".into()))?;
let json_tradeoffer = json!({
"newversion": true,
"version": 3,
"me": {
"assets": my_assets,
"currency": [],
"ready": false,
},
"them": {
"assets": their_assets,
"currency": [],
"ready": false,
}
});
let mut form = Vec::new();
form.push(("serverid", "1".to_string()));
form.push(("partner", parsed.steam_id.steam_id64().to_string()));
form.push(("tradeoffermessage", message.to_string()));
form.push(("json_tradeoffer", json_tradeoffer.to_string()));
form.push(("captcha", "".to_string()));
form.push(("trade_offer_create_params", json!({ "trade_offer_access_token": parsed.token }).to_string()));
let response = self.post_path("/tradeoffer/new/send").header(reqwest::header::REFERER, trade_url).form(&form).send().await?.text().await?;
let raw: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
if let Some(str_error) = raw.get("strError").and_then(|v| v.as_str()) {
let clean = str_error.replace("<br>", "\n").replace("<br/>", "\n");
let clean = re_html_tags().replace_all(&clean, "").to_string();
return Err(SteamUserError::Other(clean.trim().to_string()));
}
let result: TradeOfferResult = serde_json::from_value(raw).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
Ok(result)
}
#[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/accept", kind = Write)]
pub async fn accept_trade_offer(&self, trade_offer_id: u64, partner_steam_id: Option<String>) -> Result<String, SteamUserError> {
let partner_id = if let Some(id) = partner_steam_id {
id
} else {
let page = self.get_path(format!("/tradeoffer/{}", trade_offer_id)).send().await?.text().await?;
if let Some(start) = page.find("var g_ulTradePartnerSteamID = '") {
let rest = &page[start + "var g_ulTradePartnerSteamID = '".len()..];
if let Some(end) = rest.find("';") {
rest[..end].to_string()
} else {
return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
}
} else {
return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
}
};
let form = vec![("serverid", "1".to_string()), ("tradeofferid", trade_offer_id.to_string()), ("partner", partner_id), ("captcha", String::new())];
let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
let response = self.post_path(format!("/tradeoffer/{}/accept", trade_offer_id)).header(reqwest::header::REFERER, &referer).form(&form).send().await?.text().await?;
let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
if let Some(trade_id) = data.get("tradeid").and_then(|v| v.as_str()) {
Ok(trade_id.to_string())
} else {
Err(SteamUserError::Other(format!("Unexpected response: {}", response)))
}
}
#[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/decline", kind = Write)]
pub async fn decline_trade_offer(&self, trade_offer_id: u64) -> Result<(), SteamUserError> {
let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
let response = self.post_path(format!("/tradeoffer/{}/decline", trade_offer_id)).header(reqwest::header::REFERER, &referer).form(&([] as [(&str, &str); 0])).send().await?.text().await?;
let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
let success = data.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false);
if success || data.get("tradeofferid").is_some() || data.get("tradeoffer_id").is_some() {
Ok(())
} else {
Err(SteamUserError::Other(format!("Failed to decline trade offer: {}", response)))
}
}
pub fn parse_trade_url(&self, trade_url: &str) -> Option<ParsedTradeURL> {
let url = url::Url::parse(trade_url).ok()?;
let partner = url.query_pairs().find(|(k, _)| k == "partner")?.1.to_string();
let token = url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| v.to_string());
let account_id = partner.parse::<u32>().ok()?;
let steam_id = SteamID::from_individual_account_id(account_id);
Some(ParsedTradeURL { account_id: partner, steam_id, token })
}
}
fn parse_steam_banner_timestamp(banner_text: &str) -> Option<i64> {
let at_pos = banner_text.find('@');
if at_pos.is_none() {
tracing::debug!("[TradeOffer] No '@' in banner text: {:?}", banner_text);
return None;
}
let at_pos = at_pos?;
let time_part = banner_text[at_pos + 1..].trim(); let date_part = banner_text[..at_pos].trim();
let date_start = date_part.find(|c: char| c.is_ascii_digit())?;
let date_str = date_part[date_start..].trim();
let time_token = time_part.split_whitespace().next().unwrap_or(time_part);
let am_pm = if time_token.ends_with("am") {
"AM"
} else if time_token.ends_with("pm") {
"PM"
} else {
tracing::debug!("[TradeOffer] Cannot determine AM/PM from time_token: {:?} (full time_part: {:?})", time_token, time_part);
return None;
};
let time_digits = time_token.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let mut time_split = time_digits.split(':');
let mut hour: u32 = time_split.next()?.trim().parse().ok()?;
let minute: u32 = time_split.next()?.trim().parse().ok()?;
if am_pm == "AM" && hour == 12 {
hour = 0;
}
if am_pm == "PM" && hour != 12 {
hour += 12;
}
let date_str = date_str.replace(',', "");
let mut parts = date_str.split_whitespace();
let day: u32 = parts.next()?.parse().ok()?;
let month_str = parts.next()?;
let year: i32 = parts.next()?.parse().ok()?;
let month = match month_str {
"Jan" => 1,
"Feb" => 2,
"Mar" => 3,
"Apr" => 4,
"May" => 5,
"Jun" => 6,
"Jul" => 7,
"Aug" => 8,
"Sep" => 9,
"Oct" => 10,
"Nov" => 11,
"Dec" => 12,
_ => {
tracing::debug!("[TradeOffer] Unknown month: {:?}", month_str);
return None;
}
};
let steam_offset = FixedOffset::west_opt(7 * 3600)?;
let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
let dt = steam_offset.from_local_datetime(&naive).single()?;
let ts = dt.timestamp();
tracing::debug!("[TradeOffer] Parsed banner timestamp: {:?} -> UTC {}", banner_text, ts);
Some(ts)
}
fn decode_js_unicode_escapes(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' && chars.peek() == Some(&'u') {
chars.next(); let hex: String = chars.by_ref().take(4).collect();
if hex.len() == 4 {
if let Ok(code) = u32::from_str_radix(&hex, 16) {
if (0xD800..=0xDBFF).contains(&code) {
let mut low_chars = chars.clone();
if low_chars.next() == Some('\\') && low_chars.next() == Some('u') {
let low_hex: String = low_chars.by_ref().take(4).collect();
if let Ok(low_code) = u32::from_str_radix(&low_hex, 16) {
if (0xDC00..=0xDFFF).contains(&low_code) {
let cp = 0x10000 + ((code - 0xD800) << 10) + (low_code - 0xDC00);
if let Some(c) = char::from_u32(cp) {
result.push(c);
for _ in 0..6 {
chars.next();
}
continue;
}
}
}
}
result.push(char::REPLACEMENT_CHARACTER);
} else if let Some(c) = char::from_u32(code) {
result.push(c);
} else {
result.push(char::REPLACEMENT_CHARACTER);
}
} else {
result.push_str("\\u");
result.push_str(&hex);
}
} else {
result.push_str("\\u");
result.push_str(&hex);
}
} else {
result.push(ch);
}
}
result
}