steampie 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
steampie/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # flake8: noqa
2
+ __title__ = "steampie"
3
+ __author__ = "offish"
4
+ __version__ = "0.2.0"
5
+ __license__ = "MIT"
6
+
7
+ from .client import SteamClient, SteamGuardLoginClient
8
+ from .exceptions import *
9
+ from .models import Asset, Currency, GameOptions, TradeOfferState
10
+ from .utils import *
steampie/client.py ADDED
@@ -0,0 +1,617 @@
1
+ import json
2
+ import re
3
+ import time
4
+ import urllib.parse as urlparse
5
+
6
+ import requests
7
+
8
+ from .confirmation import ConfirmationExecutor
9
+ from .exceptions import (
10
+ ApiException,
11
+ InvalidCredentials,
12
+ SevenDaysHoldException,
13
+ SteampieException,
14
+ TooManyRequests,
15
+ )
16
+ from .guard import load_steam_guard
17
+ from .login import LoginExecutor
18
+ from .models import Asset, GameOptions, PredefinedOptions, SteamUrl, TradeOfferState
19
+ from .utils import (
20
+ account_id_to_steam_id,
21
+ get_description_key,
22
+ get_key_value_from_url,
23
+ login_required,
24
+ merge_items_with_descriptions_from_inventory,
25
+ merge_items_with_descriptions_from_offer,
26
+ merge_items_with_descriptions_from_offers,
27
+ ping_proxy,
28
+ steam_id_to_account_id,
29
+ text_between,
30
+ texts_between,
31
+ )
32
+
33
+
34
+ class SteamClient:
35
+ def __init__(
36
+ self,
37
+ api_key: str,
38
+ username: str,
39
+ password: str,
40
+ shared_secret: str,
41
+ identity_secret: str,
42
+ login_cookies: dict | None = None,
43
+ proxies: dict | None = None,
44
+ steam_id: str | None = None,
45
+ ) -> None:
46
+ self._api_key = api_key
47
+ self._session = requests.Session()
48
+
49
+ if proxies:
50
+ self.set_proxies(proxies)
51
+
52
+ self.was_login_executed = False
53
+ self.username = username
54
+ self._password = password
55
+ self._shared_secret = shared_secret
56
+ self._identity_secret = identity_secret
57
+ self._access_token = None
58
+
59
+ # None or str at this point
60
+ self._steam_id = steam_id
61
+
62
+ if login_cookies:
63
+ self.set_login_cookies(login_cookies)
64
+
65
+ def set_proxies(self, proxies: dict) -> dict:
66
+ if not isinstance(proxies, dict):
67
+ raise TypeError(
68
+ "Proxy must be a dict. Example: "
69
+ + "{'http': 'http://login:password@host:port', "
70
+ + "'https': 'http://login:password@host:port'}",
71
+ )
72
+
73
+ if ping_proxy(proxies):
74
+ self._session.proxies.update(proxies)
75
+
76
+ return proxies
77
+
78
+ def set_login_cookies(self, cookies: dict) -> None:
79
+ self._session.cookies.update(cookies)
80
+ self.was_login_executed = True
81
+
82
+ @login_required
83
+ def get_steam_id(self) -> str:
84
+ url = SteamUrl.COMMUNITY_URL
85
+ response = self._session.get(url)
86
+
87
+ if steam_id := re.search(r'g_steamID = "(\d+)";', response.text):
88
+ return steam_id.group(1)
89
+
90
+ raise ValueError(f"Invalid steam_id: {steam_id}")
91
+
92
+ def login(self) -> None:
93
+ # session is alive, no need to login again
94
+ if self.was_login_executed and self.is_session_alive():
95
+ return
96
+
97
+ self._session.cookies.set("steamRememberLogin", "true")
98
+ LoginExecutor(
99
+ self.username,
100
+ self._password,
101
+ self._shared_secret,
102
+ self._session,
103
+ ).login()
104
+
105
+ self.was_login_executed = True
106
+ self._access_token = self._set_access_token()
107
+
108
+ if self._steam_id is None:
109
+ self._steam_id = self.get_steam_id()
110
+
111
+ def _set_access_token(self) -> str:
112
+ steam_login_secure_cookies = [
113
+ cookie
114
+ for cookie in self._session.cookies
115
+ if cookie.name == "steamLoginSecure"
116
+ ]
117
+ cookie_value = steam_login_secure_cookies[0].value
118
+ decoded_cookie_value = urlparse.unquote(cookie_value)
119
+ access_token_parts = decoded_cookie_value.split("||")
120
+
121
+ if len(access_token_parts) < 2:
122
+ print(decoded_cookie_value)
123
+ raise ValueError("Access token not found in steamLoginSecure cookie")
124
+
125
+ access_token = access_token_parts[1]
126
+ return access_token
127
+
128
+ @login_required
129
+ def logout(self) -> None:
130
+ url = f"{SteamUrl.COMMUNITY_URL}/login/logout/"
131
+ data = {"sessionid": self._get_session_id()}
132
+ self._session.post(url, data=data)
133
+
134
+ if self.is_session_alive():
135
+ raise SteampieException("Logout unsuccessful")
136
+
137
+ self.was_login_executed = False
138
+
139
+ def __enter__(self):
140
+ self.login()
141
+ return self
142
+
143
+ def __exit__(self, exc_type, exc_val, exc_tb):
144
+ del exc_type, exc_val, exc_tb
145
+ self.logout()
146
+
147
+ @login_required
148
+ def is_session_alive(self) -> bool:
149
+ steam_login = self.username
150
+ main_page_response = self._session.get(SteamUrl.COMMUNITY_URL)
151
+ return steam_login.lower() in main_page_response.text.lower()
152
+
153
+ def api_call(
154
+ self,
155
+ method: str,
156
+ interface: str,
157
+ api_method: str,
158
+ version: str,
159
+ params: dict | None = None,
160
+ ) -> requests.Response:
161
+ url = f"{SteamUrl.API_URL}/{interface}/{api_method}/{version}"
162
+ response = None
163
+
164
+ if method == "GET":
165
+ response = self._session.get(url, params=params)
166
+ else:
167
+ response = self._session.post(url, data=params)
168
+
169
+ if self.is_invalid_api_key(response):
170
+ raise InvalidCredentials("Invalid API key")
171
+
172
+ return response
173
+
174
+ @staticmethod
175
+ def is_invalid_api_key(response: requests.Response) -> bool:
176
+ msg = "Access is denied. Retrying will not help. Please verify your <pre>key=</pre> parameter"
177
+ return msg in response.text
178
+
179
+ @login_required
180
+ def get_my_inventory(
181
+ self, game: GameOptions | str, merge: bool = True, count: int = 5000
182
+ ) -> dict:
183
+ return self.get_partner_inventory(self.steam_id, game, merge, count)
184
+
185
+ @login_required
186
+ def get_partner_inventory(
187
+ self,
188
+ partner_steam_id: str,
189
+ game: GameOptions | str,
190
+ context_id: str = "2",
191
+ merge: bool = True,
192
+ count: int = 5000,
193
+ ) -> dict:
194
+ app_id = game
195
+ context_id = context_id
196
+
197
+ if isinstance(game, PredefinedOptions):
198
+ app_id = game.app_id
199
+ context_id = game.context_id
200
+
201
+ if not isinstance(app_id, str):
202
+ raise SteampieException("Invalid app_id type")
203
+
204
+ url = f"{SteamUrl.COMMUNITY_URL}/inventory/{partner_steam_id}/{app_id}/{context_id}"
205
+ params = {"l": "english", "count": count}
206
+
207
+ full_response = self._session.get(url, params=params)
208
+ response_dict = full_response.json()
209
+
210
+ if full_response.status_code == 429:
211
+ raise TooManyRequests("Too many requests, try again later.")
212
+
213
+ if response_dict is None or response_dict.get("success") != 1:
214
+ raise ApiException("Success value should be 1.")
215
+
216
+ return (
217
+ merge_items_with_descriptions_from_inventory(response_dict, game)
218
+ if merge
219
+ else response_dict
220
+ )
221
+
222
+ def _get_session_id(self) -> str | None:
223
+ response = self._session.cookies.get_dict(domain="steamcommunity.com", path="/")
224
+ return response.get("sessionid")
225
+
226
+ def get_trade_offers_summary(self) -> dict:
227
+ params = {"key": self._api_key}
228
+ response = self.api_call(
229
+ "GET", "IEconService", "GetTradeOffersSummary", "v1", params
230
+ )
231
+ return response.json()
232
+
233
+ def _set_token_params(self, params: dict, use_webtoken: bool) -> dict:
234
+ if use_webtoken:
235
+ params["access_token"] = self._access_token
236
+ else:
237
+ params["key"] = self._api_key
238
+
239
+ return params
240
+
241
+ def get_trade_offers(
242
+ self,
243
+ merge: bool = True,
244
+ get_sent_offers: bool = True,
245
+ get_received_offers: bool = True,
246
+ use_webtoken: bool = False,
247
+ max_retry: int = 5,
248
+ ) -> dict:
249
+ params = {
250
+ "get_sent_offers": int(get_sent_offers),
251
+ "get_received_offers": int(get_received_offers),
252
+ "get_descriptions": 1,
253
+ "language": "english",
254
+ "active_only": 1,
255
+ "historical_only": 0,
256
+ "time_historical_cutoff": "",
257
+ }
258
+
259
+ params = self._set_token_params(params, use_webtoken)
260
+ response = self._try_to_get_trade_offers(params, max_retry)
261
+
262
+ if response is None:
263
+ raise ApiException("Cannot get proper json from get_trade_offers method")
264
+
265
+ response_with_active_offers = self._filter_non_active_offers(response)
266
+
267
+ if not merge:
268
+ return response_with_active_offers
269
+
270
+ return merge_items_with_descriptions_from_offers(response_with_active_offers)
271
+
272
+ def _try_to_get_trade_offers(self, params: dict, max_retry: int) -> dict | None:
273
+ response = None
274
+
275
+ for _ in range(max_retry):
276
+ try:
277
+ response = self.api_call(
278
+ "GET", "IEconService", "GetTradeOffers", "v1", params
279
+ ).json()
280
+ break
281
+ except json.decoder.JSONDecodeError:
282
+ time.sleep(2)
283
+
284
+ return response
285
+
286
+ @staticmethod
287
+ def _filter_non_active_offers(offers_response):
288
+ offers_received = offers_response["response"].get("trade_offers_received", [])
289
+ offers_sent = offers_response["response"].get("trade_offers_sent", [])
290
+
291
+ offers_response["response"]["trade_offers_received"] = list(
292
+ filter(
293
+ lambda offer: offer["trade_offer_state"] == TradeOfferState.Active,
294
+ offers_received,
295
+ ),
296
+ )
297
+ offers_response["response"]["trade_offers_sent"] = list(
298
+ filter(
299
+ lambda offer: offer["trade_offer_state"] == TradeOfferState.Active,
300
+ offers_sent,
301
+ ),
302
+ )
303
+
304
+ return offers_response
305
+
306
+ def get_trade_offer(
307
+ self, trade_offer_id: str, merge: bool = True, use_webtoken: bool = False
308
+ ) -> dict:
309
+ params = {"tradeofferid": trade_offer_id, "language": "english"}
310
+ params = self._set_token_params(params, use_webtoken)
311
+
312
+ response = self.api_call(
313
+ "GET", "IEconService", "GetTradeOffer", "v1", params
314
+ ).json()
315
+
316
+ if merge and "descriptions" in response["response"]:
317
+ descriptions = {
318
+ get_description_key(offer): offer
319
+ for offer in response["response"]["descriptions"]
320
+ }
321
+ offer = response["response"]["offer"]
322
+ response["response"]["offer"] = merge_items_with_descriptions_from_offer(
323
+ offer, descriptions
324
+ )
325
+
326
+ return response
327
+
328
+ def get_trade_history(
329
+ self,
330
+ max_trades: int = 100,
331
+ start_after_time=None,
332
+ start_after_tradeid=None,
333
+ get_descriptions: bool = True,
334
+ navigating_back: bool = True,
335
+ include_failed: bool = True,
336
+ include_total: bool = True,
337
+ ) -> dict:
338
+ params = {
339
+ "key": self._api_key,
340
+ "max_trades": max_trades,
341
+ "start_after_time": start_after_time,
342
+ "start_after_tradeid": start_after_tradeid,
343
+ "get_descriptions": get_descriptions,
344
+ "navigating_back": navigating_back,
345
+ "include_failed": include_failed,
346
+ "include_total": include_total,
347
+ }
348
+ response = self.api_call("GET", "IEconService", "GetTradeHistory", "v1", params)
349
+ return response.json()
350
+
351
+ @login_required
352
+ def get_trade_receipt(self, trade_id: str):
353
+ html = self._session.get(
354
+ f"https://steamcommunity.com/trade/{trade_id}/receipt"
355
+ ).content.decode()
356
+ return [
357
+ json.loads(item) for item in texts_between(html, "oItem = ", ";\r\n\toItem")
358
+ ]
359
+
360
+ @login_required
361
+ def accept_trade_offer(self, trade_offer_id: str) -> dict:
362
+ trade = self.get_trade_offer(trade_offer_id, use_webtoken=True)
363
+ trade_offer_state = TradeOfferState(
364
+ trade["response"]["offer"]["trade_offer_state"]
365
+ )
366
+
367
+ if trade_offer_state is not TradeOfferState.Active:
368
+ raise ApiException(
369
+ f"Invalid trade offer state: {trade_offer_state.name} ({trade_offer_state.value})"
370
+ )
371
+
372
+ partner = self._fetch_trade_partner_id(trade_offer_id)
373
+ session_id = self._get_session_id()
374
+ accept_url = f"{SteamUrl.COMMUNITY_URL}/tradeoffer/{trade_offer_id}/accept"
375
+ params = {
376
+ "sessionid": session_id,
377
+ "tradeofferid": trade_offer_id,
378
+ "serverid": "1",
379
+ "partner": partner,
380
+ "captcha": "",
381
+ }
382
+ headers = {"Referer": self._get_trade_offer_url(trade_offer_id)}
383
+
384
+ response = self._session.post(accept_url, data=params, headers=headers).json()
385
+
386
+ if response.get("needs_mobile_confirmation", False):
387
+ return self._confirm_transaction(trade_offer_id)
388
+
389
+ return response
390
+
391
+ def _fetch_trade_partner_id(self, trade_offer_id: str) -> str:
392
+ url = self._get_trade_offer_url(trade_offer_id)
393
+ offer_response_text = self._session.get(url).text
394
+
395
+ if (
396
+ "You have logged in from a new device. In order to protect the items"
397
+ in offer_response_text
398
+ ):
399
+ raise SevenDaysHoldException(
400
+ "Account has logged in a new device and can't trade for 7 days"
401
+ )
402
+
403
+ return text_between(
404
+ offer_response_text, "var g_ulTradePartnerSteamID = '", "';"
405
+ )
406
+
407
+ def _confirm_transaction(self, trade_offer_id: str) -> dict:
408
+ confirmation_executor = ConfirmationExecutor(
409
+ self._identity_secret,
410
+ self.steam_id,
411
+ self._session,
412
+ )
413
+ return confirmation_executor.send_trade_allow_request(trade_offer_id)
414
+
415
+ def decline_trade_offer(self, trade_offer_id: str) -> dict:
416
+ url = f"https://steamcommunity.com/tradeoffer/{trade_offer_id}/decline"
417
+ response = self._session.post(url, data={"sessionid": self._get_session_id()})
418
+ return response.json()
419
+
420
+ def cancel_trade_offer(self, trade_offer_id: str) -> dict:
421
+ url = f"https://steamcommunity.com/tradeoffer/{trade_offer_id}/cancel"
422
+ response = self._session.post(url, data={"sessionid": self._get_session_id()})
423
+ return response.json()
424
+
425
+ @login_required
426
+ def make_offer(
427
+ self,
428
+ items_from_me: list[Asset],
429
+ items_from_them: list[Asset],
430
+ partner_steam_id: str,
431
+ message: str = "",
432
+ ) -> dict:
433
+ offer = self._create_offer_dict(items_from_me, items_from_them)
434
+ session_id = self._get_session_id()
435
+ url = f"{SteamUrl.COMMUNITY_URL}/tradeoffer/new/send"
436
+ server_id = 1
437
+ params = {
438
+ "sessionid": session_id,
439
+ "serverid": server_id,
440
+ "partner": partner_steam_id,
441
+ "tradeoffermessage": message,
442
+ "json_tradeoffer": json.dumps(offer),
443
+ "captcha": "",
444
+ "trade_offer_create_params": "{}",
445
+ }
446
+ partner_account_id = steam_id_to_account_id(partner_steam_id)
447
+ headers = {
448
+ "Referer": f"{SteamUrl.COMMUNITY_URL}/tradeoffer/new/?partner={partner_account_id}",
449
+ "Origin": SteamUrl.COMMUNITY_URL,
450
+ }
451
+ response = self._session.post(url, data=params, headers=headers).json()
452
+
453
+ if response.get("needs_mobile_confirmation"):
454
+ response.update(self._confirm_transaction(response["tradeofferid"]))
455
+
456
+ return response
457
+
458
+ def get_profile(self, steam_id: str) -> dict:
459
+ params = {"steamids": steam_id, "key": self._api_key}
460
+ response = self.api_call(
461
+ "GET", "ISteamUser", "GetPlayerSummaries", "v0002", params
462
+ )
463
+ data = response.json()
464
+ return data["response"]["players"][0]
465
+
466
+ def get_friend_list(self, steam_id: str, relationship_filter: str = "all") -> dict:
467
+ params = {
468
+ "key": self._api_key,
469
+ "steamid": steam_id,
470
+ "relationship": relationship_filter,
471
+ }
472
+ resp = self.api_call("GET", "ISteamUser", "GetFriendList", "v1", params)
473
+ data = resp.json()
474
+ return data["friendslist"]["friends"]
475
+
476
+ @staticmethod
477
+ def _create_offer_dict(
478
+ items_from_me: list[Asset], items_from_them: list[Asset]
479
+ ) -> dict:
480
+ return {
481
+ "newversion": True,
482
+ "version": 4,
483
+ "me": {
484
+ "assets": [asset.to_dict() for asset in items_from_me],
485
+ "currency": [],
486
+ "ready": False,
487
+ },
488
+ "them": {
489
+ "assets": [asset.to_dict() for asset in items_from_them],
490
+ "currency": [],
491
+ "ready": False,
492
+ },
493
+ }
494
+
495
+ @login_required
496
+ def get_escrow_duration(self, trade_offer_url: str) -> int:
497
+ headers = {
498
+ "Referer": f"{SteamUrl.COMMUNITY_URL}{urlparse.urlparse(trade_offer_url).path}",
499
+ "Origin": SteamUrl.COMMUNITY_URL,
500
+ }
501
+ response = self._session.get(trade_offer_url, headers=headers).text
502
+
503
+ my_escrow_duration = int(text_between(response, "var g_daysMyEscrow = ", ";"))
504
+ their_escrow_duration = int(
505
+ text_between(response, "var g_daysTheirEscrow = ", ";")
506
+ )
507
+
508
+ return max(my_escrow_duration, their_escrow_duration)
509
+
510
+ @login_required
511
+ def make_offer_with_url(
512
+ self,
513
+ items_from_me: list[Asset],
514
+ items_from_them: list[Asset],
515
+ trade_offer_url: str,
516
+ message: str = "",
517
+ case_sensitive: bool = True,
518
+ confirm_trade: bool = True,
519
+ ) -> dict:
520
+ token = get_key_value_from_url(trade_offer_url, "token", case_sensitive)
521
+ partner_account_id = get_key_value_from_url(
522
+ trade_offer_url, "partner", case_sensitive
523
+ )
524
+ partner_steam_id = account_id_to_steam_id(partner_account_id)
525
+ offer = self._create_offer_dict(items_from_me, items_from_them)
526
+ session_id = self._get_session_id()
527
+ url = f"{SteamUrl.COMMUNITY_URL}/tradeoffer/new/send"
528
+ server_id = 1
529
+ trade_offer_create_params = {"trade_offer_access_token": token}
530
+ params = {
531
+ "sessionid": session_id,
532
+ "serverid": server_id,
533
+ "partner": partner_steam_id,
534
+ "tradeoffermessage": message,
535
+ "json_tradeoffer": json.dumps(offer),
536
+ "captcha": "",
537
+ "trade_offer_create_params": json.dumps(trade_offer_create_params),
538
+ }
539
+
540
+ headers = {
541
+ "Referer": f"{SteamUrl.COMMUNITY_URL}{urlparse.urlparse(trade_offer_url).path}",
542
+ "Origin": SteamUrl.COMMUNITY_URL,
543
+ }
544
+
545
+ response = self._session.post(url, data=params, headers=headers).json()
546
+
547
+ if confirm_trade and response.get("needs_mobile_confirmation"):
548
+ response.update(self._confirm_transaction(response["tradeofferid"]))
549
+
550
+ return response
551
+
552
+ def _friend_ajax_request(self, method: str, steam_id: str, accept: int = 0) -> dict:
553
+ assert method.lower() in ["add", "remove"]
554
+
555
+ headers = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
556
+ url = f"{SteamUrl.COMMUNITY_URL}/actions/AddFriendAjax"
557
+ data = {
558
+ "sessionID": self._get_session_id(),
559
+ "steamid": steam_id,
560
+ "accept_invite": accept,
561
+ }
562
+
563
+ if method == "remove":
564
+ url = f"{SteamUrl.COMMUNITY_URL}/actions/RemoveFriendAjax"
565
+ del data["accept_invite"]
566
+
567
+ response = self._session.post(url, data=data, headers=headers)
568
+
569
+ if response.status_code != 200:
570
+ raise ApiException(
571
+ f"Request failed with status code {response.status_code}"
572
+ )
573
+
574
+ return response.json()
575
+
576
+ @login_required
577
+ def remove_friend(self, steam_id: str) -> dict:
578
+ return self._friend_ajax_request("remove", steam_id)
579
+
580
+ @login_required
581
+ def add_friend(self, steam_id: str) -> dict:
582
+ return self._friend_ajax_request("add", steam_id)
583
+
584
+ @login_required
585
+ def accept_friend(self, steam_id: str) -> dict:
586
+ return self._friend_ajax_request("add", steam_id, accept=1)
587
+
588
+ @staticmethod
589
+ def _get_trade_offer_url(trade_offer_id: str) -> str:
590
+ return f"{SteamUrl.COMMUNITY_URL}/tradeoffer/{trade_offer_id}"
591
+
592
+ @property
593
+ def steam_id(self) -> str:
594
+ return self._steam_id
595
+
596
+
597
+ class SteamGuardLoginClient(SteamClient):
598
+ def __init__(
599
+ self,
600
+ api_key: str,
601
+ password: str,
602
+ steam_guard_file: str,
603
+ ) -> None:
604
+ steam_guard = load_steam_guard(steam_guard_file)
605
+ username = steam_guard["account_name"]
606
+ shared_secret = steam_guard["shared_secret"]
607
+ identity_secret = steam_guard["identity_secret"]
608
+ steam_id = str(steam_guard["Session"]["SteamID"])
609
+
610
+ super().__init__(
611
+ api_key=api_key,
612
+ username=username,
613
+ password=password,
614
+ shared_secret=shared_secret,
615
+ identity_secret=identity_secret,
616
+ steam_id=steam_id,
617
+ )