1use std::sync::OnceLock;
4
5use chrono::{FixedOffset, TimeZone};
6use regex::Regex;
7use scraper::{Html, Selector};
8use serde_json::json;
9use steamid::SteamID;
10
11use crate::{
12 client::SteamUser,
13 endpoint::steam_endpoint,
14 error::SteamUserError,
15 types::{ParsedTradeURL, TradeOffer, TradeOfferAsset, TradeOfferItem, TradeOfferItems, TradeOfferPartner, TradeOfferResult, TradeOfferStatus, TradeOfferSummary, TradeOffersResponse},
16};
17
18static SEL_TRADE_URL: OnceLock<Selector> = OnceLock::new();
22fn sel_trade_url() -> &'static Selector {
23 SEL_TRADE_URL.get_or_init(|| Selector::parse("#trade_offer_access_url").expect("valid CSS selector"))
24}
25
26static SEL_SUBPAGE: OnceLock<Selector> = OnceLock::new();
27fn sel_subpage() -> &'static Selector {
28 SEL_SUBPAGE.get_or_init(|| Selector::parse(".profile_subpage_selector > a").expect("valid CSS selector"))
29}
30
31static SEL_ESCROW: OnceLock<Selector> = OnceLock::new();
32fn sel_escrow() -> &'static Selector {
33 SEL_ESCROW.get_or_init(|| Selector::parse(".trade_offers_escrow_explanation > .title").expect("valid CSS selector"))
34}
35
36static SEL_TRADEOFFER: OnceLock<Selector> = OnceLock::new();
37fn sel_tradeoffer() -> &'static Selector {
38 SEL_TRADEOFFER.get_or_init(|| Selector::parse(".profile_leftcol > .tradeoffer").expect("valid CSS selector"))
39}
40
41static SEL_PLAYER_AVATAR: OnceLock<Selector> = OnceLock::new();
42fn sel_player_avatar() -> &'static Selector {
43 SEL_PLAYER_AVATAR.get_or_init(|| Selector::parse(".playerAvatar").expect("valid CSS selector"))
44}
45
46static SEL_IMG: OnceLock<Selector> = OnceLock::new();
47fn sel_img() -> &'static Selector {
48 SEL_IMG.get_or_init(|| Selector::parse("img").expect("valid CSS selector"))
49}
50
51static SEL_A: OnceLock<Selector> = OnceLock::new();
52fn sel_a() -> &'static Selector {
53 SEL_A.get_or_init(|| Selector::parse("a").expect("valid CSS selector"))
54}
55
56static SEL_TRADEOFFER_HEADER: OnceLock<Selector> = OnceLock::new();
57fn sel_tradeoffer_header() -> &'static Selector {
58 SEL_TRADEOFFER_HEADER.get_or_init(|| Selector::parse(".tradeoffer_header").expect("valid CSS selector"))
59}
60
61static SEL_ITEMS_CTN: OnceLock<Selector> = OnceLock::new();
62fn sel_items_ctn() -> &'static Selector {
63 SEL_ITEMS_CTN.get_or_init(|| Selector::parse(".tradeoffer_items_ctn").expect("valid CSS selector"))
64}
65
66static SEL_PRIMARY_ITEMS: OnceLock<Selector> = OnceLock::new();
67fn sel_primary_items() -> &'static Selector {
68 SEL_PRIMARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.primary").expect("valid CSS selector"))
69}
70
71static SEL_SECONDARY_ITEMS: OnceLock<Selector> = OnceLock::new();
72fn sel_secondary_items() -> &'static Selector {
73 SEL_SECONDARY_ITEMS.get_or_init(|| Selector::parse(".tradeoffer_items_ctn .tradeoffer_items.secondary").expect("valid CSS selector"))
74}
75
76static SEL_AVATAR_LINK: OnceLock<Selector> = OnceLock::new();
77fn sel_avatar_link() -> &'static Selector {
78 SEL_AVATAR_LINK.get_or_init(|| Selector::parse("a.tradeoffer_avatar").expect("valid CSS selector"))
79}
80
81static SEL_TRADE_ITEM: OnceLock<Selector> = OnceLock::new();
82fn sel_trade_item() -> &'static Selector {
83 SEL_TRADE_ITEM.get_or_init(|| Selector::parse(".tradeoffer_item_list > .trade_item").expect("valid CSS selector"))
84}
85
86static SEL_ITEMS_BANNER: OnceLock<Selector> = OnceLock::new();
87fn sel_items_banner() -> &'static Selector {
88 SEL_ITEMS_BANNER.get_or_init(|| Selector::parse(".tradeoffer_items_banner").expect("valid CSS selector"))
89}
90
91static RE_HTML_TAGS: OnceLock<Regex> = OnceLock::new();
92fn re_html_tags() -> &'static Regex {
93 RE_HTML_TAGS.get_or_init(|| Regex::new(r"<[^>]+>").expect("valid regex"))
94}
95
96impl SteamUser {
97 #[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/privacy", kind = Read)]
103 pub async fn get_trade_url(&self) -> Result<Option<String>, SteamUserError> {
104 let response = self.get_path("/my/tradeoffers/privacy").send().await?.text().await?;
105
106 let html = Html::parse_document(&response);
107
108 Ok(html.select(sel_trade_url()).next().and_then(|el| el.value().attr("value").map(|v| v.to_string())))
109 }
110
111 #[steam_endpoint(GET, host = Community, path = "/my/tradeoffers/", kind = Read)]
116 pub async fn get_trade_offer(&self) -> Result<TradeOffersResponse, SteamUserError> {
117 let response = self.get_with_manual_redirects("https://steamcommunity.com/my/tradeoffers/").await?;
118
119 let html = Html::parse_document(&response);
120
121 let mut incoming_offers = TradeOfferSummary { link: String::new(), count: 0 };
122 let mut sent_offers = TradeOfferSummary { link: String::new(), count: 0 };
123 let mut trade_hold_count = 0;
124
125 for el in html.select(sel_subpage()) {
126 let text = el.text().collect::<String>();
127 let link = el.value().attr("href").unwrap_or("").trim_end_matches('/').to_string();
128 let mut count = 0;
129 if let (Some(start), Some(end)) = (text.find('('), text.find(')')) {
130 count = text[start + 1..end].trim().parse().unwrap_or(0);
131 }
132
133 if link.ends_with("tradeoffers") {
134 incoming_offers = TradeOfferSummary { link, count };
135 } else if link.ends_with("tradeoffers/sent") {
136 sent_offers = TradeOfferSummary { link, count };
137 }
138 }
139
140 if let Some(el) = html.select(sel_escrow()).next() {
141 let text = el.text().collect::<String>().trim().to_string();
142 if text.ends_with("trade on hold") || text.ends_with("trades on hold") {
143 trade_hold_count = text.split(' ').next().and_then(|s| s.parse().ok()).unwrap_or(0);
144 }
145 }
146
147 let mut trade_offers = Vec::new();
148 for el in html.select(sel_tradeoffer()) {
149 let id_str = el.value().attr("id").unwrap_or("");
150 if !id_str.starts_with("tradeofferid_") {
151 continue;
152 }
153 let tradeofferid = id_str["tradeofferid_".len()..].parse().unwrap_or(0);
154 if tradeofferid == 0 {
155 continue;
156 }
157
158 let html_content = el.html();
159 let partner_data = if let Some(start) = html_content.find("ReportTradeScam(") {
160 let rest = &html_content[start + "ReportTradeScam(".len()..];
161 if let Some(end) = rest.find(");") {
162 let part = rest[..end].trim();
163 let parts: Vec<String> = part
164 .split(',')
165 .map(|s| {
166 let decoded = s.trim().replace(""", "\"").replace("&", "&").replace("<", "<").replace(">", ">").replace("'", "'");
168 decoded.trim_matches(|c: char| c == '\'' || c == '"').trim().to_string()
169 })
170 .collect();
171 if parts.len() >= 2 {
172 let steam_id = parts[0].parse::<SteamID>().ok();
177 let name = decode_js_unicode_escapes(&parts[1]);
178 Some((steam_id, name))
179 } else {
180 None
181 }
182 } else {
183 None
184 }
185 } else {
186 None
187 };
188
189 let (partner_steam_id, partner_name) = partner_data.unwrap_or_default();
190
191 let player_avatar = el.select(sel_player_avatar()).next();
192 let avatar_url = player_avatar.and_then(|a| a.select(sel_img()).next()).and_then(|i| i.value().attr("src")).unwrap_or("");
193 let avatar_hash = if let Some(pos) = avatar_url.rfind('/') {
194 let filename = &avatar_url[pos + 1..];
195 if let Some(dot_pos) = filename.find('.') {
196 filename[..dot_pos].to_string()
197 } else {
198 filename.to_string()
199 }
200 } else {
201 String::new()
202 };
203
204 let link = player_avatar.and_then(|a| a.select(sel_a()).next()).and_then(|l| l.value().attr("href")).unwrap_or("").to_string();
205 let header = el.select(sel_tradeoffer_header()).next().map(|h| h.text().collect::<String>().trim().to_string()).unwrap_or_default();
206 let active = el.select(sel_items_ctn()).next().map(|c| c.value().has_class("active", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false);
207
208 let primary_items_ctn = el.select(sel_primary_items()).next();
209 let secondary_items_ctn = el.select(sel_secondary_items()).next();
210
211 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);
212
213 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);
214
215 let parse_items = |ctn: Option<scraper::ElementRef>| {
216 let mut items = Vec::new();
217 if let Some(ctn) = ctn {
218 for item_el in ctn.select(sel_trade_item()) {
219 let economy_item = item_el.value().attr("data-economy-item").unwrap_or("").to_string();
220 let img = item_el.select(sel_img()).next().and_then(|i| i.value().attr("src")).unwrap_or("").to_string();
221 let img_hi = item_el
223 .select(sel_img())
224 .next()
225 .and_then(|i| i.value().attr("srcset"))
226 .and_then(|srcset| {
227 srcset.split(',').find_map(|entry| {
228 let entry = entry.trim();
229 if entry.ends_with("2x") {
230 Some(entry.trim_end_matches("2x").trim().to_string())
231 } else {
232 None
233 }
234 })
235 })
236 .unwrap_or_default();
237 let missing = item_el.value().has_class("missing", scraper::CaseSensitivity::AsciiCaseInsensitive);
238 items.push(TradeOfferItem { economy_item, img, img_hi, missing });
239 }
240 }
241 items
242 };
243
244 let banner_el = el.select(sel_items_banner()).next();
246 let banner_text = banner_el.map(|b| b.text().collect::<String>().trim().to_string()).unwrap_or_default();
247 let status = if active {
248 TradeOfferStatus::Active
249 } else if banner_el.map(|b| b.value().has_class("accepted", scraper::CaseSensitivity::AsciiCaseInsensitive)).unwrap_or(false) {
250 TradeOfferStatus::Accepted
251 } else if banner_text.contains("Unavailable") || banner_text.contains("unavailable") {
252 TradeOfferStatus::Unavailable
253 } else if banner_text.contains("Counter") || banner_text.contains("counter") {
254 TradeOfferStatus::Countered
255 } else {
256 TradeOfferStatus::Inactive
257 };
258
259 let banner_timestamp = parse_steam_banner_timestamp(&banner_text);
260
261 trade_offers.push(TradeOffer {
262 tradeofferid,
263 partner: TradeOfferPartner { steamid: partner_steam_id, name: partner_name, avatar_hash, link, header },
264 active,
265 primary: TradeOfferItems { steamid: primary_steam_id, items: parse_items(primary_items_ctn) },
266 secondary: TradeOfferItems { steamid: secondary_steam_id, items: parse_items(secondary_items_ctn) },
267 banner_text,
268 banner_timestamp,
269 status,
270 });
271 }
272
273 Ok(TradeOffersResponse { incoming_offers, sent_offers, trade_hold_count, trade_offers })
274 }
275
276 #[steam_endpoint(POST, host = Community, path = "/tradeoffer/new/send", kind = Write)]
287 pub async fn send_trade_offer(&self, trade_url: &str, my_assets: Vec<TradeOfferAsset>, their_assets: Vec<TradeOfferAsset>, message: &str) -> Result<TradeOfferResult, SteamUserError> {
288 let parsed = self.parse_trade_url(trade_url).ok_or_else(|| SteamUserError::Other("Invalid trade URL".into()))?;
289
290 let json_tradeoffer = json!({
291 "newversion": true,
292 "version": 3,
293 "me": {
294 "assets": my_assets,
295 "currency": [],
296 "ready": false,
297 },
298 "them": {
299 "assets": their_assets,
300 "currency": [],
301 "ready": false,
302 }
303 });
304
305 let mut form = Vec::new();
306 form.push(("serverid", "1".to_string()));
307 form.push(("partner", parsed.steam_id.steam_id64().to_string()));
308 form.push(("tradeoffermessage", message.to_string()));
309 form.push(("json_tradeoffer", json_tradeoffer.to_string()));
310 form.push(("captcha", "".to_string()));
311 form.push(("trade_offer_create_params", json!({ "trade_offer_access_token": parsed.token }).to_string()));
312
313 let response = self.post_path("/tradeoffer/new/send").header(reqwest::header::REFERER, trade_url).form(&form).send().await?.text().await?;
314
315 let raw: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
318 if let Some(str_error) = raw.get("strError").and_then(|v| v.as_str()) {
319 let clean = str_error.replace("<br>", "\n").replace("<br/>", "\n");
321 let clean = re_html_tags().replace_all(&clean, "").to_string();
322 return Err(SteamUserError::Other(clean.trim().to_string()));
323 }
324
325 let result: TradeOfferResult = serde_json::from_value(raw).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
326 Ok(result)
327 }
328
329 #[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/accept", kind = Write)]
337 pub async fn accept_trade_offer(&self, trade_offer_id: u64, partner_steam_id: Option<String>) -> Result<String, SteamUserError> {
338 let partner_id = if let Some(id) = partner_steam_id {
339 id
340 } else {
341 let page = self.get_path(format!("/tradeoffer/{}", trade_offer_id)).send().await?.text().await?;
342 if let Some(start) = page.find("var g_ulTradePartnerSteamID = '") {
343 let rest = &page[start + "var g_ulTradePartnerSteamID = '".len()..];
344 if let Some(end) = rest.find("';") {
345 rest[..end].to_string()
346 } else {
347 return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
348 }
349 } else {
350 return Err(SteamUserError::MalformedResponse("Failed to extract partner Steam ID".into()));
351 }
352 };
353
354 let form = vec![("serverid", "1".to_string()), ("tradeofferid", trade_offer_id.to_string()), ("partner", partner_id), ("captcha", String::new())];
355
356 let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
357 let response = self.post_path(format!("/tradeoffer/{}/accept", trade_offer_id)).header(reqwest::header::REFERER, &referer).form(&form).send().await?.text().await?;
358
359 let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
360 if let Some(trade_id) = data.get("tradeid").and_then(|v| v.as_str()) {
361 Ok(trade_id.to_string())
362 } else {
363 Err(SteamUserError::Other(format!("Unexpected response: {}", response)))
364 }
365 }
366
367 #[steam_endpoint(POST, host = Community, path = "/tradeoffer/{trade_offer_id}/decline", kind = Write)]
373 pub async fn decline_trade_offer(&self, trade_offer_id: u64) -> Result<(), SteamUserError> {
374 let referer = format!("https://steamcommunity.com/tradeoffer/{}/", trade_offer_id);
375 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?;
376
377 let data: serde_json::Value = serde_json::from_str(&response).map_err(|e| SteamUserError::Other(format!("Failed to parse response: {}", e)))?;
378 let success = data.get("success").and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|i| i == 1))).unwrap_or(false);
379
380 if success || data.get("tradeofferid").is_some() || data.get("tradeoffer_id").is_some() {
381 Ok(())
382 } else {
383 Err(SteamUserError::Other(format!("Failed to decline trade offer: {}", response)))
384 }
385 }
386
387 pub fn parse_trade_url(&self, trade_url: &str) -> Option<ParsedTradeURL> {
389 let url = url::Url::parse(trade_url).ok()?;
390 let partner = url.query_pairs().find(|(k, _)| k == "partner")?.1.to_string();
391 let token = url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| v.to_string());
392
393 let account_id = partner.parse::<u32>().ok()?;
394 let steam_id = SteamID::from_individual_account_id(account_id);
395
396 Some(ParsedTradeURL { account_id: partner, steam_id, token })
397 }
398}
399
400fn parse_steam_banner_timestamp(banner_text: &str) -> Option<i64> {
407 let at_pos = banner_text.find('@');
410 if at_pos.is_none() {
411 tracing::debug!("[TradeOffer] No '@' in banner text: {:?}", banner_text);
412 return None;
413 }
414 let at_pos = at_pos?;
415 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())?;
420 let date_str = date_part[date_start..].trim(); let time_token = time_part.split_whitespace().next().unwrap_or(time_part);
425 let am_pm = if time_token.ends_with("am") {
426 "AM"
427 } else if time_token.ends_with("pm") {
428 "PM"
429 } else {
430 tracing::debug!("[TradeOffer] Cannot determine AM/PM from time_token: {:?} (full time_part: {:?})", time_token, time_part);
431 return None;
432 };
433 let time_digits = time_token.trim_end_matches(|c: char| c.is_ascii_alphabetic());
434 let mut time_split = time_digits.split(':');
435 let mut hour: u32 = time_split.next()?.trim().parse().ok()?;
436 let minute: u32 = time_split.next()?.trim().parse().ok()?;
437
438 if am_pm == "AM" && hour == 12 {
440 hour = 0;
441 }
442 if am_pm == "PM" && hour != 12 {
443 hour += 12;
444 }
445
446 let date_str = date_str.replace(',', "");
448 let mut parts = date_str.split_whitespace();
449 let day: u32 = parts.next()?.parse().ok()?;
450 let month_str = parts.next()?;
451 let year: i32 = parts.next()?.parse().ok()?;
452
453 let month = match month_str {
454 "Jan" => 1,
455 "Feb" => 2,
456 "Mar" => 3,
457 "Apr" => 4,
458 "May" => 5,
459 "Jun" => 6,
460 "Jul" => 7,
461 "Aug" => 8,
462 "Sep" => 9,
463 "Oct" => 10,
464 "Nov" => 11,
465 "Dec" => 12,
466 _ => {
467 tracing::debug!("[TradeOffer] Unknown month: {:?}", month_str);
468 return None;
469 }
470 };
471
472 let steam_offset = FixedOffset::west_opt(7 * 3600)?;
474 let naive = chrono::NaiveDate::from_ymd_opt(year, month, day)?.and_hms_opt(hour, minute, 0)?;
475 let dt = steam_offset.from_local_datetime(&naive).single()?;
476 let ts = dt.timestamp();
477 tracing::debug!("[TradeOffer] Parsed banner timestamp: {:?} -> UTC {}", banner_text, ts);
478 Some(ts)
479}
480
481fn decode_js_unicode_escapes(s: &str) -> String {
484 let mut result = String::with_capacity(s.len());
485 let mut chars = s.chars().peekable();
486 while let Some(ch) = chars.next() {
487 if ch == '\\' && chars.peek() == Some(&'u') {
488 chars.next(); let hex: String = chars.by_ref().take(4).collect();
490 if hex.len() == 4 {
491 if let Ok(code) = u32::from_str_radix(&hex, 16) {
492 if (0xD800..=0xDBFF).contains(&code) {
494 let mut low_chars = chars.clone();
496 if low_chars.next() == Some('\\') && low_chars.next() == Some('u') {
497 let low_hex: String = low_chars.by_ref().take(4).collect();
498 if let Ok(low_code) = u32::from_str_radix(&low_hex, 16) {
499 if (0xDC00..=0xDFFF).contains(&low_code) {
500 let cp = 0x10000 + ((code - 0xD800) << 10) + (low_code - 0xDC00);
501 if let Some(c) = char::from_u32(cp) {
502 result.push(c);
503 for _ in 0..6 {
505 chars.next();
506 }
507 continue;
508 }
509 }
510 }
511 }
512 result.push(char::REPLACEMENT_CHARACTER);
514 } else if let Some(c) = char::from_u32(code) {
515 result.push(c);
516 } else {
517 result.push(char::REPLACEMENT_CHARACTER);
518 }
519 } else {
520 result.push_str("\\u");
521 result.push_str(&hex);
522 }
523 } else {
524 result.push_str("\\u");
525 result.push_str(&hex);
526 }
527 } else {
528 result.push(ch);
529 }
530 }
531 result
532}