1#![allow(dead_code)]
19#![deny(
21 missing_debug_implementations,
22 missing_copy_implementations,
23 trivial_casts,
24 trivial_numeric_casts,
25 unsafe_code,
26 unused_import_braces,
27 unused_qualifications
28)]
29
30use std::str::FromStr;
31use std::sync::Arc;
32use std::time::Duration;
33
34use const_format::concatcp;
35pub use errors::ConfirmationError;
36pub use errors::InternalError;
37pub use errors::OfferError;
38pub use errors::TradeError;
39pub use errors::TradelinkError;
40use futures::stream::FuturesOrdered;
41use futures::StreamExt;
42use futures::TryFutureExt;
43use futures_timer::Delay;
44use serde::de::DeserializeOwned;
45use steam_language_gen::generated::enums::ETradeOfferState;
46use steam_mobile::user::PresentMaFile;
47use steam_mobile::Authenticated;
48use steam_mobile::ConfirmationAction;
49use steam_mobile::HeaderMap;
50use steam_mobile::Method;
51use steam_mobile::SteamAuthenticator;
52use steam_mobile::STEAM_COMMUNITY_HOST;
53use steamid_parser::SteamID;
54use tappet::response_types::GetTradeHistoryResponse;
55use tappet::response_types::GetTradeOffersResponse;
56use tappet::response_types::TradeHistory_Trade;
57use tappet::response_types::TradeOffer_Trade;
58use tappet::ExecutorResponse;
59use tappet::SteamAPI;
60use tracing::debug;
61pub use types::asset_collection::AssetCollection;
62pub use types::trade_link::Tradelink;
63pub use types::trade_offer::TradeOffer;
64
65use crate::additional_checks::check_steam_guard_error;
66use crate::api_extensions::FilterBy;
67use crate::api_extensions::HasAssets;
68use crate::errors::error_from_strmessage;
69use crate::errors::tradeoffer_error_from_eresult;
70use crate::errors::TradeError::GeneralError;
71use crate::types::sessionid::HasSessionID;
72use crate::types::trade_offer_web::TradeOfferAcceptRequest;
73use crate::types::trade_offer_web::TradeOfferCancelResponse;
74use crate::types::trade_offer_web::TradeOfferCommonParameters;
75use crate::types::trade_offer_web::TradeOfferCreateRequest;
76use crate::types::trade_offer_web::TradeOfferCreateResponse;
77use crate::types::trade_offer_web::TradeOfferGenericErrorResponse;
78use crate::types::trade_offer_web::TradeOfferGenericRequest;
79use crate::types::trade_offer_web::TradeOfferParams;
80use crate::types::TradeKind;
81
82mod additional_checks;
83pub mod api_extensions;
84mod errors;
85#[cfg(feature = "time")]
86pub mod time;
87mod types;
88
89const TRADEOFFER_BASE: &str = "https://steamcommunity.com/tradeoffer/";
90const TRADEOFFER_NEW_URL: &str = concatcp!(TRADEOFFER_BASE, "new/send");
91
92pub const TRADE_MAX_ITEMS: u8 = u8::MAX;
97
98pub const TRADE_MAX_TRADES_PER_SINGLE_USER: u8 = 5;
100
101pub const TRADE_MAX_ONGOING_TRADES: u8 = 30;
103
104const STANDARD_DELAY: u64 = 1000;
106
107const MAX_HISTORICAL_CUTOFF: u32 = u32::MAX;
108
109pub(crate) type SteamCompleteAuthenticator = SteamAuthenticator<Authenticated, PresentMaFile>;
110
111#[derive(Debug)]
112pub struct SteamTradeManager<'a> {
113 authenticator: &'a SteamCompleteAuthenticator,
114 api_client: SteamAPI,
115}
116
117impl<'a> SteamTradeManager<'a> {
118 pub fn new(
124 authenticator: &'a SteamAuthenticator<Authenticated, PresentMaFile>,
125 ) -> Result<SteamTradeManager<'a>, TradeError> {
126 let api_key = authenticator
127 .api_key()
128 .ok_or_else(|| GeneralError("Can't build without an API Key cached.".to_string()))?;
129
130 Ok(Self {
131 authenticator,
132 api_client: SteamAPI::new(api_key),
133 })
134 }
135
136 pub async fn check_steam_guard_recently_activated(&self, tradelink: Tradelink) -> Result<(), TradeError> {
138 let Tradelink { partner_id, token, .. } = tradelink;
139
140 check_steam_guard_error(self.authenticator, partner_id, &*token)
141 .err_into()
142 .await
143 }
144
145 pub async fn get_trade_offers(
149 &self,
150 sent: bool,
151 received: bool,
152 active_only: bool,
153 ) -> Result<GetTradeOffersResponse, TradeError> {
154 self.api_client
155 .get()
156 .IEconService()
157 .GetTradeOffers(
158 sent,
159 received,
160 MAX_HISTORICAL_CUTOFF,
161 Some(active_only),
162 None,
163 None,
164 None,
165 )
166 .execute_with_response()
167 .err_into()
168 .await
169 }
170
171 async fn get_trade_offers_history(
177 &self,
178 max_trades: Option<u32>,
179 include_failed: bool,
180 ) -> Result<GetTradeHistoryResponse, TradeError> {
181 let max_trades = max_trades.unwrap_or(500);
182
183 self.api_client
184 .get()
185 .IEconService()
186 .GetTradeHistory(max_trades, include_failed, false, None, None, None, None, None)
187 .execute_with_response()
188 .err_into()
189 .await
190 }
191
192 pub async fn get_tradeoffer_by_id(&self, tradeoffer_id: u64) -> Result<Vec<TradeOffer_Trade>, TradeError> {
194 self.get_trade_offers(true, true, true)
195 .map_ok(|tradeoffers| tradeoffers.filter_by(|offer| offer.tradeofferid == tradeoffer_id))
196 .await
197 }
198
199 pub async fn get_new_assetids(&self, tradeid: i64) -> Result<Vec<i64>, TradeError> {
203 let found_trade: TradeHistory_Trade = self
204 .get_trade_offers_history(None, false)
205 .map_ok(|tradeoffers| tradeoffers.filter_by(|trade| trade.tradeid == tradeid))
206 .await?
207 .swap_remove(0);
208
209 Ok(found_trade
210 .every_asset()
211 .into_iter()
212 .map(|traded_asset| traded_asset.new_assetid)
213 .collect::<Vec<_>>())
214 }
215
216 pub async fn decline_received_offers(&self) -> Result<(), TradeError> {
220 let mut deny_offers_fut = FuturesOrdered::new();
221
222 let active_received_offers: Vec<TradeOffer_Trade> = self
223 .get_trade_offers(true, true, true)
224 .map_ok(|tradeoffers| {
225 tradeoffers.filter_by(|offer| offer.state == ETradeOfferState::Active && !offer.is_our_offer)
226 })
227 .await?;
228
229 let total = active_received_offers.len();
230 active_received_offers
231 .into_iter()
232 .map(|x| x.tradeofferid)
233 .for_each(|x| {
234 deny_offers_fut.push_back(
235 self.deny_offer(x)
236 .map_ok(|_| Delay::new(Duration::from_millis(STANDARD_DELAY))),
237 );
238 });
239
240 while let Some(result) = deny_offers_fut.next().await {
241 match result {
242 Ok(_) => {}
243 Err(e) => return Err(e),
244 }
245 }
246
247 debug!("Successfully denied a total of {} received offers.", total);
248
249 Ok(())
250 }
251
252 pub async fn create_offer_and_confirm(&self, tradeoffer: TradeOffer) -> Result<u64, TradeError> {
256 let tradeoffer_id = self.create_offer(tradeoffer).await?;
257 Delay::new(Duration::from_millis(STANDARD_DELAY));
258 self.accept_offer(tradeoffer_id).await?;
259 Ok(tradeoffer_id)
260 }
261
262 pub async fn create_offer(&self, tradeoffer: TradeOffer) -> Result<u64, TradeError> {
264 self.request::<TradeOfferCreateResponse>(TradeKind::Create(tradeoffer), None)
265 .map_ok(|c| {
266 c.tradeofferid
267 .and_then(|id| u64::from_str(&id).ok())
268 .expect("Safe to unwrap.")
269 })
270 .await
271 }
272
273 pub async fn accept_offer(&self, tradeoffer_id: u64) -> Result<(), TradeError> {
277 let resp: TradeOfferCreateResponse = self.request(TradeKind::Accept, Some(tradeoffer_id)).await?;
278
279 if resp.needs_mobile_confirmation.is_none() && !resp.needs_mobile_confirmation.unwrap() {
280 return Ok(());
281 }
282
283 let confirmation = self
284 .authenticator
285 .fetch_confirmations()
286 .inspect_ok(|_| debug!("Confirmations fetched successfully."))
287 .await?
288 .into_iter()
289 .find(|c| c.has_trade_offer_id(tradeoffer_id))
290 .ok_or_else(|| TradeError::from(ConfirmationError::NotFound))?;
291
292 self.authenticator
293 .process_confirmations(ConfirmationAction::Accept, std::iter::once(confirmation))
294 .err_into()
295 .await
296 }
297
298 pub async fn deny_offer(&self, tradeoffer_id: u64) -> Result<(), TradeError> {
304 self.request::<TradeOfferCancelResponse>(TradeKind::Decline, Some(tradeoffer_id))
305 .await
306 .map(|_| ())
307 }
308
309 pub async fn cancel_offer(&self, tradeoffer_id: u64) -> Result<(), TradeError> {
315 self.request::<TradeOfferCancelResponse>(TradeKind::Cancel, Some(tradeoffer_id))
316 .await
317 .map(|_| ())
318 }
319
320 async fn request<OUTPUT>(&self, operation: TradeKind, tradeoffer_id: Option<u64>) -> Result<OUTPUT, TradeError>
322 where
323 OUTPUT: DeserializeOwned + Send + Sync,
324 {
325 let tradeoffer_endpoint = operation.endpoint(tradeoffer_id);
326
327 let mut header: Option<HeaderMap> = None;
328 let mut partner_id_and_token = None;
329
330 match &operation {
331 TradeKind::Create(offer) => {
332 header.replace(HeaderMap::new());
333 header
334 .as_mut()
335 .unwrap()
336 .insert("Referer", (TRADEOFFER_BASE.to_owned() + "new").parse().unwrap());
337
338 partner_id_and_token = Some((
339 offer.their_tradelink.partner_id.clone(),
340 offer.their_tradelink.token.clone(),
341 ));
342 }
343 TradeKind::Accept => {
344 header.replace(HeaderMap::new());
345 header.as_mut().unwrap().insert(
346 "Referer",
347 format!("{}{}/", TRADEOFFER_BASE, tradeoffer_id.unwrap())
348 .parse()
349 .unwrap(),
350 );
351 }
352 _ => {}
353 };
354
355 let mut request: Box<dyn HasSessionID> = match operation {
356 TradeKind::Accept => {
357 let partner_id = self
358 .get_tradeoffer_by_id(tradeoffer_id.unwrap())
359 .await?
360 .first()
361 .map(|c| SteamID::from_steam3(c.tradeofferid as u32, None, None))
362 .map(|steamid| steamid.to_steam64())
363 .ok_or(OfferError::NoMatch)?;
364
365 let trade_request_data = TradeOfferAcceptRequest {
366 common: TradeOfferCommonParameters {
367 their_steamid: partner_id,
368 ..Default::default()
369 },
370 tradeofferid: tradeoffer_id.unwrap(),
371 ..Default::default()
372 };
373
374 debug!("{:#}", serde_json::to_string_pretty(&trade_request_data).unwrap());
375 Box::new(trade_request_data)
376 }
377
378 TradeKind::Cancel | TradeKind::Decline => Box::<TradeOfferGenericRequest>::default(),
379 TradeKind::Create(offer) => Box::new(Self::prepare_offer(offer)?),
380 };
381
382 let session_id_cookie = self
384 .authenticator
385 .dump_cookie(STEAM_COMMUNITY_HOST, "sessionid")
386 .ok_or_else(|| {
387 GeneralError("Somehow you don't have a sessionid cookie. You need to login first.".to_string())
388 })?;
389
390 request.set_sessionid(session_id_cookie);
391 let request: Arc<dyn HasSessionID> = Arc::from(request);
392
393 let response = self
394 .authenticator
395 .request_custom_endpoint(tradeoffer_endpoint, Method::POST, header, Some(request))
396 .err_into::<InternalError>()
397 .await?;
398 let response_text = response.text().err_into::<InternalError>().await?;
399
400 match serde_json::from_str::<OUTPUT>(&response_text) {
401 Ok(response) => Ok(response),
402 Err(_) => {
403 if let Ok(resp) = serde_json::from_str::<TradeOfferGenericErrorResponse>(&response_text) {
405 if resp.error_message.is_some() {
406 let err_msg = resp.error_message.unwrap();
407 Err(error_from_strmessage(&*err_msg).unwrap().into())
408 } else if resp.eresult.is_some() {
409 let eresult = resp.eresult.unwrap();
410 Err(tradeoffer_error_from_eresult(eresult).into())
411 } else {
412 tracing::error!("Unable to understand Steam Response. Please report it as bug.");
413 Err(OfferError::GeneralFailure(format!(
414 "Steam Response: {}\nThis is a bug, please report it.",
415 response_text
416 ))
417 .into())
418 }
419 } else {
420 if let Some((steamid, token)) = partner_id_and_token {
421 check_steam_guard_error(self.authenticator, steamid, &token).await?;
422 }
423
424 tracing::error!(
425 "Failure to deserialize a valid response Steam Offer response. Maybe Steam Servers are \
426 offline."
427 );
428 Err(OfferError::GeneralFailure(format!("Steam Response: {}", response_text)).into())
429 }
430 }
431 }
432 }
433
434 fn prepare_offer(tradeoffer: TradeOffer) -> Result<TradeOfferCreateRequest, TradeError> {
437 TradeOffer::validate(&tradeoffer.my_assets, &tradeoffer.their_assets)?;
438
439 let tradelink = tradeoffer.their_tradelink.clone();
440
441 let their_steamid64 = tradelink.partner_id.to_steam64();
442 let trade_offer_params = TradeOfferParams {
443 trade_offer_access_token: tradelink.token,
444 };
445
446 Ok(TradeOfferCreateRequest::new(
447 their_steamid64,
448 tradeoffer,
449 trade_offer_params,
450 ))
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 fn get_tradeoffer_url_with_token() -> &'static str {
459 "https://steamcommunity.com/tradeoffer/new/?partner=79925588&token=Ob27qXzn"
460 }
461
462 fn get_tradeoffer_url_without_token() -> &'static str {
463 "https://steamcommunity.com/tradeoffer/new/?partner=79925588"
464 }
465
466 #[allow(clippy::too_many_lines)]
467 fn sample_trade_history_response() -> GetTradeHistoryResponse {
468 let response = r#"{
469 "response": {
470 "more": true,
471 "trades": [
472 {
473 "tradeid": "3622543526924228084",
474 "steamid_other": "76561198040191316",
475 "time_init": 1603998438,
476 "status": 3,
477 "assets_given": [
478 {
479 "appid": 730,
480 "contextid": "2",
481 "assetid": "15319724006",
482 "amount": "1",
483 "classid": "3035569977",
484 "instanceid": "302028390",
485 "new_assetid": "19793871926",
486 "new_contextid": "2"
487 }
488 ]
489 },
490 {
491 "tradeid": "3151905948742966439",
492 "steamid_other": "76561198040191316",
493 "time_init": 1594190957,
494 "status": 3,
495 "assets_received": [
496 {
497 "appid": 730,
498 "contextid": "2",
499 "assetid": "17300115678",
500 "amount": "1",
501 "classid": "1989330488",
502 "instanceid": "302028390",
503 "new_assetid": "19034292089",
504 "new_contextid": "2"
505 }
506 ]
507 },
508 {
509 "tradeid": "3151905948742946486",
510 "steamid_other": "76561198040191316",
511 "time_init": 1594190486,
512 "status": 3,
513 "assets_received": [
514 {
515 "appid": 730,
516 "contextid": "2",
517 "assetid": "17341684309",
518 "amount": "1",
519 "classid": "1989279043",
520 "instanceid": "302028390",
521 "new_assetid": "19034259977",
522 "new_contextid": "2"
523 }
524 ]
525 },
526 {
527 "tradeid": "3151905948734426645",
528 "steamid_other": "76561198017653157",
529 "time_init": 1593990409,
530 "status": 3,
531 "assets_received": [
532 {
533 "appid": 730,
534 "contextid": "2",
535 "assetid": "8246208960",
536 "amount": "1",
537 "classid": "310776668",
538 "instanceid": "302028390",
539 "new_assetid": "19019879428",
540 "new_contextid": "2"
541 },
542 {
543 "appid": 730,
544 "contextid": "2",
545 "assetid": "11589364986",
546 "amount": "1",
547 "classid": "469467368",
548 "instanceid": "302028390",
549 "new_assetid": "19019879441",
550 "new_contextid": "2"
551 }
552 ]
553 },
554 {
555 "tradeid": "2816382071757670028",
556 "steamid_other": "76561198040191316",
557 "time_init": 1587519425,
558 "status": 3,
559 "assets_received": [
560 {
561 "appid": 730,
562 "contextid": "2",
563 "assetid": "17921552800",
564 "amount": "1",
565 "classid": "1989286992",
566 "instanceid": "302028390",
567 "new_assetid": "18426035472",
568 "new_contextid": "2"
569 }
570 ]
571 },
572 {
573 "tradeid": "2289455842905057389",
574 "steamid_other": "76561198994791561",
575 "time_init": 1582942255,
576 "time_escrow_end": 1584238255,
577 "status": 3,
578 "assets_given": [
579 {
580 "appid": 730,
581 "contextid": "2",
582 "assetid": "16832065568",
583 "amount": "1",
584 "classid": "1989312177",
585 "instanceid": "302028390",
586 "new_assetid": "18074934023",
587 "new_contextid": "2"
588 }
589 ]
590 },
591 {
592 "tradeid": "2022547174628342555",
593 "steamid_other": "76561197966598809",
594 "time_init": 1515645117,
595 "status": 3,
596 "assets_received": [
597 {
598 "appid": 730,
599 "contextid": "2",
600 "assetid": "4345999",
601 "amount": "1",
602 "classid": "310777161",
603 "instanceid": "188530139",
604 "new_assetid": "13327873664",
605 "new_contextid": "2"
606 }
607 ]
608 },
609 {
610 "tradeid": "2022547174628335361",
611 "steamid_other": "76561197976600825",
612 "time_init": 1515644947,
613 "status": 3,
614 "assets_received": [
615 {
616 "appid": 730,
617 "contextid": "2",
618 "assetid": "12792180950",
619 "amount": "1",
620 "classid": "2521767801",
621 "instanceid": "0",
622 "new_assetid": "13327860916",
623 "new_contextid": "2"
624 },
625 {
626 "appid": 447820,
627 "contextid": "2",
628 "assetid": "1667881814169014779",
629 "amount": "1",
630 "classid": "2219693199",
631 "instanceid": "0",
632 "new_assetid": "1827766562939536102",
633 "new_contextid": "2"
634 }
635 ]
636 },
637 {
638 "tradeid": "2022547174624411155",
639 "steamid_other": "76561197971392179",
640 "time_init": 1515552781,
641 "status": 3,
642 "assets_received": [
643 {
644 "appid": 730,
645 "contextid": "2",
646 "assetid": "13213233361",
647 "amount": "1",
648 "classid": "2521767801",
649 "instanceid": "0",
650 "new_assetid": "13314519275",
651 "new_contextid": "2"
652 },
653 {
654 "appid": 447820,
655 "contextid": "2",
656 "assetid": "2364813217118906230",
657 "amount": "1",
658 "classid": "2219693201",
659 "instanceid": "0",
660 "new_assetid": "1827766492051349432",
661 "new_contextid": "2"
662 },
663 {
664 "appid": 578080,
665 "contextid": "2",
666 "assetid": "1807498550407081772",
667 "amount": "1",
668 "classid": "2451623575",
669 "instanceid": "0",
670 "new_assetid": "1827766492051349437",
671 "new_contextid": "2"
672 }
673 ]
674 },
675 {
676 "tradeid": "1640843092290105607",
677 "steamid_other": "76561197998993178",
678 "time_init": 1492806587,
679 "status": 3,
680 "assets_given": [
681 {
682 "appid": 730,
683 "contextid": "2",
684 "assetid": "4063307518",
685 "amount": "1",
686 "classid": "310779465",
687 "instanceid": "188530139",
688 "new_assetid": "9937692380",
689 "new_contextid": "2"
690 }
691 ]
692 }
693 ]
694 }
695}
696"#;
697 serde_json::from_str::<GetTradeHistoryResponse>(&response).unwrap()
698 }
699
700 #[test]
701 fn new_assets() {
702 let raw_response = sample_trade_history_response();
703 let filtered = raw_response.filter_by(|x| x.tradeid == 3622543526924228084).remove(0);
704 let asset = filtered.every_asset().remove(0);
705 assert_eq!(asset.assetid, 15319724006);
706 assert_eq!(asset.new_assetid, 19793871926);
707 }
708
709 #[cfg(feature = "time")]
710 #[test]
711 fn estimate_time() {
712 use crate::time::estimate_tradelock_end;
713 use crate::time::ONE_WEEK_SECONDS;
714
715 let raw_response = sample_trade_history_response();
716 let filtered_trade = raw_response.filter_by(|x| x.tradeid == 3622543526924228084).remove(0);
717 let trade_completed_time = filtered_trade.time_init;
718 assert_eq!(
719 estimate_tradelock_end(trade_completed_time, ONE_WEEK_SECONDS).timestamp(),
720 1604649600
721 );
722 }
723}