python-xbox 0.1.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.
Files changed (64) hide show
  1. python_xbox-0.1.0.dist-info/METADATA +217 -0
  2. python_xbox-0.1.0.dist-info/RECORD +64 -0
  3. python_xbox-0.1.0.dist-info/WHEEL +4 -0
  4. python_xbox-0.1.0.dist-info/entry_points.txt +6 -0
  5. python_xbox-0.1.0.dist-info/licenses/LICENSE +20 -0
  6. pythonxbox/__init__.py +4 -0
  7. pythonxbox/api/__init__.py +0 -0
  8. pythonxbox/api/client.py +166 -0
  9. pythonxbox/api/language.py +76 -0
  10. pythonxbox/api/provider/__init__.py +0 -0
  11. pythonxbox/api/provider/account/__init__.py +73 -0
  12. pythonxbox/api/provider/account/models.py +11 -0
  13. pythonxbox/api/provider/achievements/__init__.py +164 -0
  14. pythonxbox/api/provider/achievements/models.py +133 -0
  15. pythonxbox/api/provider/baseprovider.py +22 -0
  16. pythonxbox/api/provider/catalog/__init__.py +86 -0
  17. pythonxbox/api/provider/catalog/const.py +15 -0
  18. pythonxbox/api/provider/catalog/models.py +428 -0
  19. pythonxbox/api/provider/cqs/__init__.py +85 -0
  20. pythonxbox/api/provider/cqs/models.py +59 -0
  21. pythonxbox/api/provider/gameclips/__init__.py +167 -0
  22. pythonxbox/api/provider/gameclips/models.py +58 -0
  23. pythonxbox/api/provider/lists/__init__.py +71 -0
  24. pythonxbox/api/provider/lists/models.py +33 -0
  25. pythonxbox/api/provider/mediahub/__init__.py +64 -0
  26. pythonxbox/api/provider/mediahub/models.py +82 -0
  27. pythonxbox/api/provider/message/__init__.py +135 -0
  28. pythonxbox/api/provider/message/models.py +96 -0
  29. pythonxbox/api/provider/people/__init__.py +193 -0
  30. pythonxbox/api/provider/people/models.py +252 -0
  31. pythonxbox/api/provider/presence/__init__.py +110 -0
  32. pythonxbox/api/provider/presence/models.py +53 -0
  33. pythonxbox/api/provider/profile/__init__.py +140 -0
  34. pythonxbox/api/provider/profile/models.py +47 -0
  35. pythonxbox/api/provider/ratelimitedprovider.py +79 -0
  36. pythonxbox/api/provider/screenshots/__init__.py +167 -0
  37. pythonxbox/api/provider/screenshots/models.py +56 -0
  38. pythonxbox/api/provider/smartglass/__init__.py +402 -0
  39. pythonxbox/api/provider/smartglass/models.py +186 -0
  40. pythonxbox/api/provider/titlehub/__init__.py +143 -0
  41. pythonxbox/api/provider/titlehub/models.py +106 -0
  42. pythonxbox/api/provider/usersearch/__init__.py +29 -0
  43. pythonxbox/api/provider/usersearch/models.py +17 -0
  44. pythonxbox/api/provider/userstats/__init__.py +164 -0
  45. pythonxbox/api/provider/userstats/models.py +44 -0
  46. pythonxbox/authentication/__init__.py +0 -0
  47. pythonxbox/authentication/manager.py +161 -0
  48. pythonxbox/authentication/models.py +162 -0
  49. pythonxbox/authentication/xal.py +348 -0
  50. pythonxbox/common/__init__.py +0 -0
  51. pythonxbox/common/exceptions.py +59 -0
  52. pythonxbox/common/filetimes.py +81 -0
  53. pythonxbox/common/models.py +34 -0
  54. pythonxbox/common/ratelimits/__init__.py +268 -0
  55. pythonxbox/common/ratelimits/models.py +23 -0
  56. pythonxbox/common/request_signer.py +190 -0
  57. pythonxbox/common/signed_session.py +60 -0
  58. pythonxbox/py.typed +0 -0
  59. pythonxbox/scripts/__init__.py +15 -0
  60. pythonxbox/scripts/authenticate.py +159 -0
  61. pythonxbox/scripts/change_gamertag.py +111 -0
  62. pythonxbox/scripts/friends.py +80 -0
  63. pythonxbox/scripts/search.py +43 -0
  64. pythonxbox/scripts/xal.py +113 -0
@@ -0,0 +1,143 @@
1
+ """
2
+ Titlehub - Get Title history and info
3
+ """
4
+
5
+ from typing import TYPE_CHECKING
6
+ from pythonxbox.api.provider.baseprovider import BaseProvider
7
+ from pythonxbox.api.provider.titlehub.models import TitleFields, TitleHubResponse
8
+
9
+ if TYPE_CHECKING:
10
+ from pythonxbox.api.client import XboxLiveClient
11
+
12
+
13
+ class TitlehubProvider(BaseProvider):
14
+ TITLEHUB_URL = "https://titlehub.xboxlive.com"
15
+ SEPARATOR = ","
16
+
17
+ def __init__(self, client: "XboxLiveClient") -> None:
18
+ """
19
+ Initialize Baseclass, set 'Accept-Language' header from client instance
20
+
21
+ Args:
22
+ client (:class:`XboxLiveClient`): Instance of client
23
+ """
24
+ super().__init__(client)
25
+ self._headers = {
26
+ "x-xbl-contract-version": "2",
27
+ "x-xbl-client-name": "XboxApp",
28
+ "x-xbl-client-type": "UWA",
29
+ "x-xbl-client-version": "39.39.22001.0",
30
+ "Accept-Language": self.client.language.locale,
31
+ }
32
+
33
+ async def get_title_history(
34
+ self,
35
+ xuid: str,
36
+ fields: list[TitleFields] | None = None,
37
+ max_items: int | None = 5,
38
+ **kwargs,
39
+ ) -> TitleHubResponse:
40
+ """
41
+ Get recently played titles
42
+
43
+ Args:
44
+ xuid: Xuid
45
+ fields: List of titlefield
46
+ max_items: Maximum items
47
+
48
+ Returns:
49
+ :class:`TitleHubResponse`: Title Hub Response
50
+ """
51
+ if not fields:
52
+ fields = [
53
+ TitleFields.ACHIEVEMENT,
54
+ TitleFields.IMAGE,
55
+ TitleFields.SERVICE_CONFIG_ID,
56
+ ]
57
+ fields = self.SEPARATOR.join(fields)
58
+
59
+ url = f"{self.TITLEHUB_URL}/users/xuid({xuid})/titles/titlehistory/decoration/{fields}"
60
+ params = {"maxItems": max_items}
61
+ resp = await self.client.session.get(
62
+ url, params=params, headers=self._headers, **kwargs
63
+ )
64
+ resp.raise_for_status()
65
+ return TitleHubResponse(**resp.json())
66
+
67
+ async def _get_title_info(
68
+ self, moniker: str, fields: list[TitleFields] | None = None, **kwargs
69
+ ) -> TitleHubResponse:
70
+ if not fields:
71
+ fields = [
72
+ TitleFields.ACHIEVEMENT,
73
+ TitleFields.ALTERNATE_TITLE_ID,
74
+ TitleFields.DETAIL,
75
+ TitleFields.IMAGE,
76
+ TitleFields.SERVICE_CONFIG_ID,
77
+ ]
78
+ fields = self.SEPARATOR.join(fields)
79
+
80
+ url = f"{self.TITLEHUB_URL}/users/xuid({self.client.xuid})/titles/{moniker}/decoration/{fields}"
81
+ resp = await self.client.session.get(url, headers=self._headers, **kwargs)
82
+ resp.raise_for_status()
83
+ return TitleHubResponse(**resp.json())
84
+
85
+ async def get_title_info(
86
+ self, title_id: str, fields: list[TitleFields] | None = None, **kwargs
87
+ ) -> TitleHubResponse:
88
+ """
89
+ Get info for specific title
90
+
91
+ Args:
92
+ title_id: Title Id
93
+ fields: List of title fields
94
+
95
+ Returns:
96
+ :class:`TitleHubResponse`: Title Hub Response
97
+ """
98
+ return await self._get_title_info(f"titleid({title_id})", fields, **kwargs)
99
+
100
+ async def get_title_info_by_pfn(
101
+ self, pfn: str, fields: list[TitleFields] | None = None, **kwargs
102
+ ) -> TitleHubResponse:
103
+ """
104
+ Get info for specific title by PFN
105
+
106
+ Args:
107
+ pfn: Package family name
108
+ fields: List of title fields
109
+
110
+ Returns:
111
+ :class:`TitleHubResponse`: Title Hub Response
112
+ """
113
+ return await self._get_title_info(f"pfn({pfn})", fields, **kwargs)
114
+
115
+ async def get_titles_batch(
116
+ self, pfns: list[str], fields: list[TitleFields] | None = None, **kwargs
117
+ ) -> TitleHubResponse:
118
+ """
119
+ Get Title info via PFN ids
120
+
121
+ Args:
122
+ pfns: List of Package family names (e.g. 'Microsoft.XboxApp_8wekyb3d8bbwe')
123
+ fields: List of title fields
124
+
125
+ Returns:
126
+ :class:`TitleHubResponse`: Title Hub Response
127
+ """
128
+ if not fields:
129
+ fields = [
130
+ TitleFields.ACHIEVEMENT,
131
+ TitleFields.DETAIL,
132
+ TitleFields.IMAGE,
133
+ TitleFields.SERVICE_CONFIG_ID,
134
+ ]
135
+ fields = self.SEPARATOR.join(fields)
136
+
137
+ url = self.TITLEHUB_URL + f"/titles/batch/decoration/{fields}"
138
+ post_data = {"pfns": pfns, "windowsPhoneProductIds": []}
139
+ resp = await self.client.session.post(
140
+ url, json=post_data, headers=self._headers, **kwargs
141
+ )
142
+ resp.raise_for_status()
143
+ return TitleHubResponse(**resp.json())
@@ -0,0 +1,106 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+ from typing import Any
4
+
5
+ from pythonxbox.common.models import CamelCaseModel, PascalCaseModel
6
+
7
+
8
+ class TitleFields(str, Enum):
9
+ SERVICE_CONFIG_ID = "scid"
10
+ ACHIEVEMENT = "achievement"
11
+ STATS = "stats"
12
+ GAME_PASS = "gamepass" # noqa: S105
13
+ IMAGE = "image"
14
+ DETAIL = "detail"
15
+ FRIENDS_WHO_PLAYED = "friendswhoplayed"
16
+ ALTERNATE_TITLE_ID = "alternateTitleId"
17
+ PRODUCT_ID = "productId"
18
+ CONTENT_BOARD = "contentBoard"
19
+
20
+
21
+ class Achievement(CamelCaseModel):
22
+ current_achievements: int
23
+ total_achievements: int
24
+ current_gamerscore: int
25
+ total_gamerscore: int
26
+ progress_percentage: float
27
+ source_version: int
28
+
29
+
30
+ class Stats(CamelCaseModel):
31
+ source_version: int
32
+
33
+
34
+ class GamePass(CamelCaseModel):
35
+ is_game_pass: bool
36
+
37
+
38
+ class Image(CamelCaseModel):
39
+ url: str
40
+ type: str
41
+
42
+
43
+ class TitleHistory(CamelCaseModel):
44
+ last_time_played: datetime
45
+ visible: bool
46
+ can_hide: bool
47
+
48
+
49
+ class Attribute(CamelCaseModel):
50
+ applicable_platforms: list[str] | None = None
51
+ maximum: int | None = None
52
+ minimum: int | None = None
53
+ name: str
54
+
55
+
56
+ class Availability(PascalCaseModel):
57
+ actions: list[str]
58
+ availability_id: str
59
+ platforms: list[str]
60
+ sku_id: str
61
+
62
+
63
+ class Detail(CamelCaseModel):
64
+ attributes: list[Attribute]
65
+ availabilities: list[Availability]
66
+ capabilities: list[str]
67
+ description: str
68
+ developer_name: str | None = None
69
+ genres: list[str] | None = None
70
+ publisher_name: str
71
+ min_age: int | None = None
72
+ release_date: datetime | None = None
73
+ short_description: str | None = None
74
+ vui_display_name: str | None = None
75
+ xbox_live_gold_required: bool
76
+
77
+
78
+ class Title(CamelCaseModel):
79
+ title_id: str
80
+ pfn: str | None = None
81
+ bing_id: str | None = None
82
+ service_config_id: str | None = None
83
+ windows_phone_product_id: str | None = None
84
+ name: str
85
+ type: str
86
+ devices: list[str]
87
+ display_image: str
88
+ media_item_type: str
89
+ modern_title_id: str | None = None
90
+ is_bundle: bool
91
+ achievement: Achievement | None = None
92
+ stats: Stats | None = None
93
+ game_pass: GamePass | None = None
94
+ images: list[Image] | None = None
95
+ title_history: TitleHistory | None = None
96
+ detail: Detail | None = None
97
+ friends_who_played: Any = None
98
+ alternate_title_ids: Any = None
99
+ content_boards: Any = None
100
+ xbox_live_tier: str | None = None
101
+ is_streamable: bool | None = None
102
+
103
+
104
+ class TitleHubResponse(CamelCaseModel):
105
+ xuid: str | None = None
106
+ titles: list[Title]
@@ -0,0 +1,29 @@
1
+ """
2
+ Usersearch - Search for gamertags / userprofiles
3
+ """
4
+
5
+ from pythonxbox.api.provider.baseprovider import BaseProvider
6
+ from pythonxbox.api.provider.usersearch.models import UserSearchResponse
7
+
8
+
9
+ class UserSearchProvider(BaseProvider):
10
+ USERSEARCH_URL = "https://usersearch.xboxlive.com"
11
+ HEADERS_USER_SEARCH = {"x-xbl-contract-version": "1"}
12
+
13
+ async def get_live_search(self, query: str, **kwargs) -> UserSearchResponse:
14
+ """
15
+ Get userprofiles for search query
16
+
17
+ Args:
18
+ query: Search query
19
+
20
+ Returns:
21
+ :class:`UserSearchResponse`: User Search Response
22
+ """
23
+ url = self.USERSEARCH_URL + "/suggest"
24
+ params = {"q": query}
25
+ resp = await self.client.session.get(
26
+ url, params=params, headers=self.HEADERS_USER_SEARCH, **kwargs
27
+ )
28
+ resp.raise_for_status()
29
+ return UserSearchResponse(**resp.json())
@@ -0,0 +1,17 @@
1
+ from pythonxbox.common.models import CamelCaseModel
2
+
3
+
4
+ class UserDetail(CamelCaseModel):
5
+ id: str
6
+ gamertag: str
7
+ display_pic_uri: str
8
+ score: float
9
+
10
+
11
+ class UserResult(CamelCaseModel):
12
+ text: str
13
+ result: UserDetail
14
+
15
+
16
+ class UserSearchResponse(CamelCaseModel):
17
+ results: list[UserResult]
@@ -0,0 +1,164 @@
1
+ """
2
+ Userstats - Get game statistics
3
+ """
4
+
5
+ from pythonxbox.api.provider.ratelimitedprovider import RateLimitedProvider
6
+ from pythonxbox.api.provider.userstats.models import (
7
+ GeneralStatsField,
8
+ UserStatsResponse,
9
+ )
10
+
11
+
12
+ class UserStatsProvider(RateLimitedProvider):
13
+ USERSTATS_URL = "https://userstats.xboxlive.com"
14
+ HEADERS_USERSTATS = {"x-xbl-contract-version": "2"}
15
+ HEADERS_USERSTATS_WITH_METADATA = {"x-xbl-contract-version": "3"}
16
+ SEPERATOR = ","
17
+
18
+ # NOTE: Stats Read (userstats.xboxlive.com) and Stats Write (statswrite.xboxlive.com)
19
+ # Are mentioned as their own objects but their rate limits are the same and do not collide
20
+ # (Stats Read -> read rate limit, Stats Write -> write rate limit)
21
+ RATE_LIMITS = {"burst": 100, "sustain": 300}
22
+
23
+ async def get_stats(
24
+ self,
25
+ xuid: str,
26
+ service_config_id: str,
27
+ stats_fields: list[GeneralStatsField] | None = None,
28
+ **kwargs,
29
+ ) -> UserStatsResponse:
30
+ """
31
+ Get userstats
32
+
33
+ Args:
34
+ xuid: Xbox User Id
35
+ service_config_id: Service Config Id of Game (scid)
36
+ stats_fields: List of stats fields to acquire
37
+
38
+ Returns:
39
+ :class:`UserStatsResponse`: User Stats Response
40
+ """
41
+ if not stats_fields:
42
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
43
+ stats = self.SEPERATOR.join(stats_fields)
44
+
45
+ url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
46
+ resp = await self.client.session.get(
47
+ url,
48
+ headers=self.HEADERS_USERSTATS,
49
+ rate_limits=self.rate_limit_read,
50
+ **kwargs,
51
+ )
52
+ resp.raise_for_status()
53
+ return UserStatsResponse(**resp.json())
54
+
55
+ async def get_stats_with_metadata(
56
+ self,
57
+ xuid: str,
58
+ service_config_id: str,
59
+ stats_fields: list[GeneralStatsField] | None = None,
60
+ **kwargs,
61
+ ) -> UserStatsResponse:
62
+ """
63
+ Get userstats including metadata for each stat (if available)
64
+
65
+ Args:
66
+ xuid: Xbox User Id
67
+ service_config_id: Service Config Id of Game (scid)
68
+ stats_fields: List of stats fields to acquire
69
+
70
+ Returns:
71
+ :class:`UserStatsResponse`: User Stats Response
72
+ """
73
+ if not stats_fields:
74
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
75
+ stats = self.SEPERATOR.join(stats_fields)
76
+
77
+ url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
78
+ params = {"include": "valuemetadata"}
79
+ resp = await self.client.session.get(
80
+ url,
81
+ params=params,
82
+ headers=self.HEADERS_USERSTATS_WITH_METADATA,
83
+ rate_limits=self.rate_limit_read,
84
+ **kwargs,
85
+ )
86
+ resp.raise_for_status()
87
+ return UserStatsResponse(**resp.json())
88
+
89
+ async def get_stats_batch(
90
+ self,
91
+ xuids: list[str],
92
+ title_id: str,
93
+ stats_fields: list[GeneralStatsField] | None = None,
94
+ **kwargs,
95
+ ) -> UserStatsResponse:
96
+ """
97
+ Get userstats in batch mode
98
+
99
+ Args:
100
+ xuids: List of XUIDs to get stats for
101
+ title_id: Game Title Id
102
+ stats_fields: List of stats fields to acquire
103
+
104
+ Returns:
105
+ :class:`UserStatsResponse`: User Stats Response
106
+ """
107
+ if not stats_fields:
108
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
109
+
110
+ url = self.USERSTATS_URL + "/batch"
111
+ post_data = {
112
+ "arrangebyfield": "xuid",
113
+ "groups": [{"name": "Hero", "titleId": title_id}],
114
+ "stats": [dict(name=stat, titleId=title_id) for stat in stats_fields],
115
+ "xuids": xuids,
116
+ }
117
+ resp = await self.client.session.post(
118
+ url,
119
+ json=post_data,
120
+ headers=self.HEADERS_USERSTATS,
121
+ rate_limits=self.rate_limit_read,
122
+ **kwargs,
123
+ )
124
+ resp.raise_for_status()
125
+ return UserStatsResponse(**resp.json())
126
+
127
+ async def get_stats_batch_by_scid(
128
+ self,
129
+ xuids: list[str],
130
+ service_config_id: str,
131
+ stats_fields: list[GeneralStatsField] | None = None,
132
+ **kwargs,
133
+ ) -> UserStatsResponse:
134
+ """
135
+ Get userstats in batch mode, via scid
136
+
137
+ Args:
138
+ xuids: List of XUIDs to get stats for
139
+ service_config_id: Service Config Id of Game (scid)
140
+ stats_fields: List of stats fields to acquire
141
+
142
+ Returns:
143
+ :class:`UserStatsResponse`: User Stats Response
144
+ """
145
+ if not stats_fields:
146
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
147
+
148
+ url = self.USERSTATS_URL + "/batch"
149
+
150
+ post_data = {
151
+ "arrangebyfield": "xuid",
152
+ "groups": [{"name": "Hero", "scid": service_config_id}],
153
+ "stats": [dict(name=stat, scid=service_config_id) for stat in stats_fields],
154
+ "xuids": xuids,
155
+ }
156
+ resp = await self.client.session.post(
157
+ url,
158
+ json=post_data,
159
+ headers=self.HEADERS_USERSTATS,
160
+ rate_limits=self.rate_limit_read,
161
+ **kwargs,
162
+ )
163
+ resp.raise_for_status()
164
+ return UserStatsResponse(**resp.json())
@@ -0,0 +1,44 @@
1
+ from pythonxbox.common.models import LowerCaseModel, PascalCaseModel
2
+
3
+
4
+ class GeneralStatsField:
5
+ MINUTES_PLAYED = "MinutesPlayed"
6
+
7
+
8
+ class GroupProperties(PascalCaseModel):
9
+ ordinal: str | None = None
10
+ sort_order: str | None = None
11
+ display_name: str | None = None
12
+ display_format: str | None = None
13
+ display_semantic: str | None = None
14
+
15
+
16
+ class Properties(PascalCaseModel):
17
+ display_name: str | None = None
18
+
19
+
20
+ class Stat(LowerCaseModel):
21
+ group_properties: GroupProperties | None = None
22
+ xuid: str
23
+ scid: str
24
+ name: str
25
+ type: str
26
+ value: str
27
+ properties: Properties
28
+
29
+
30
+ class StatListsCollectionItem(LowerCaseModel):
31
+ arrange_by_field: str
32
+ arrange_by_field_id: str
33
+ stats: list[Stat]
34
+
35
+
36
+ class Group(LowerCaseModel):
37
+ name: str
38
+ title_id: str | None = None
39
+ statlistscollection: list[StatListsCollectionItem]
40
+
41
+
42
+ class UserStatsResponse(LowerCaseModel):
43
+ groups: list[Group] | None = None
44
+ statlistscollection: list[StatListsCollectionItem]
File without changes
@@ -0,0 +1,161 @@
1
+ """
2
+ Authentication Manager
3
+
4
+ Authenticate with Windows Live Server and Xbox Live.
5
+ """
6
+
7
+ import logging
8
+
9
+ import httpx
10
+
11
+ from pythonxbox.authentication.models import (
12
+ OAuth2TokenResponse,
13
+ XAUResponse,
14
+ XSTSResponse,
15
+ )
16
+ from pythonxbox.common.exceptions import AuthenticationException
17
+ from pythonxbox.common.signed_session import SignedSession
18
+
19
+ log = logging.getLogger("authentication")
20
+
21
+ DEFAULT_SCOPES = ["Xboxlive.signin", "Xboxlive.offline_access"]
22
+
23
+
24
+ class AuthenticationManager:
25
+ def __init__(
26
+ self,
27
+ client_session: SignedSession,
28
+ client_id: str,
29
+ client_secret: str,
30
+ redirect_uri: str,
31
+ scopes: list[str] | None = None,
32
+ ) -> None:
33
+ if not isinstance(client_session, (SignedSession, httpx.AsyncClient)):
34
+ raise DeprecationWarning(
35
+ """Xbox WebAPI changed to use SignedSession (wrapped httpx.AsyncClient).
36
+ Please check the documentation"""
37
+ )
38
+
39
+ self.session: SignedSession = client_session
40
+ self._client_id: str = client_id
41
+ self._client_secret: str = client_secret
42
+ self._redirect_uri: str = redirect_uri
43
+ self._scopes: list[str] = scopes or DEFAULT_SCOPES
44
+
45
+ self.oauth: OAuth2TokenResponse = None
46
+ self.user_token: XAUResponse = None
47
+ self.xsts_token: XSTSResponse = None
48
+
49
+ def generate_authorization_url(self, state: str | None = None) -> str:
50
+ """Generate Windows Live Authorization URL."""
51
+ query_params = {
52
+ "client_id": self._client_id,
53
+ "response_type": "code",
54
+ "approval_prompt": "auto",
55
+ "scope": " ".join(self._scopes),
56
+ "redirect_uri": self._redirect_uri,
57
+ }
58
+
59
+ if state:
60
+ query_params["state"] = state
61
+
62
+ return str(
63
+ httpx.URL(
64
+ "https://login.live.com/oauth20_authorize.srf", params=query_params
65
+ )
66
+ )
67
+
68
+ async def request_tokens(self, authorization_code: str) -> None:
69
+ """Request all tokens."""
70
+ self.oauth = await self.request_oauth_token(authorization_code)
71
+ self.user_token = await self.request_user_token()
72
+ self.xsts_token = await self.request_xsts_token()
73
+
74
+ async def refresh_tokens(self) -> None:
75
+ """Refresh all tokens."""
76
+ if not (self.oauth and self.oauth.is_valid()):
77
+ self.oauth = await self.refresh_oauth_token()
78
+ if not (self.user_token and self.user_token.is_valid()):
79
+ self.user_token = await self.request_user_token()
80
+ if not (self.xsts_token and self.xsts_token.is_valid()):
81
+ self.xsts_token = await self.request_xsts_token()
82
+
83
+ async def request_oauth_token(self, authorization_code: str) -> OAuth2TokenResponse:
84
+ """Request OAuth2 token."""
85
+ return await self._oauth2_token_request(
86
+ {
87
+ "grant_type": "authorization_code",
88
+ "code": authorization_code,
89
+ "scope": " ".join(self._scopes),
90
+ "redirect_uri": self._redirect_uri,
91
+ }
92
+ )
93
+
94
+ async def refresh_oauth_token(self) -> OAuth2TokenResponse:
95
+ """Refresh OAuth2 token."""
96
+ return await self._oauth2_token_request(
97
+ {
98
+ "grant_type": "refresh_token",
99
+ "scope": " ".join(self._scopes),
100
+ "refresh_token": self.oauth.refresh_token,
101
+ }
102
+ )
103
+
104
+ async def _oauth2_token_request(self, data: dict) -> OAuth2TokenResponse:
105
+ """Execute token requests."""
106
+ data["client_id"] = self._client_id
107
+ if self._client_secret:
108
+ data["client_secret"] = self._client_secret
109
+ resp = await self.session.post(
110
+ "https://login.live.com/oauth20_token.srf", data=data
111
+ )
112
+ resp.raise_for_status()
113
+ return OAuth2TokenResponse(**resp.json())
114
+
115
+ async def request_user_token(
116
+ self,
117
+ relying_party: str = "http://auth.xboxlive.com",
118
+ use_compact_ticket: bool = False,
119
+ ) -> XAUResponse:
120
+ """Authenticate via access token and receive user token."""
121
+ url = "https://user.auth.xboxlive.com/user/authenticate"
122
+ headers = {"x-xbl-contract-version": "1"}
123
+ data = {
124
+ "RelyingParty": relying_party,
125
+ "TokenType": "JWT",
126
+ "Properties": {
127
+ "AuthMethod": "RPS",
128
+ "SiteName": "user.auth.xboxlive.com",
129
+ "RpsTicket": self.oauth.access_token
130
+ if use_compact_ticket
131
+ else f"d={self.oauth.access_token}",
132
+ },
133
+ }
134
+
135
+ resp = await self.session.post(url, json=data, headers=headers)
136
+ resp.raise_for_status()
137
+ return XAUResponse(**resp.json())
138
+
139
+ async def request_xsts_token(
140
+ self, relying_party: str = "http://xboxlive.com"
141
+ ) -> XSTSResponse:
142
+ """Authorize via user token and receive final X token."""
143
+ url = "https://xsts.auth.xboxlive.com/xsts/authorize"
144
+ headers = {"x-xbl-contract-version": "1"}
145
+ data = {
146
+ "RelyingParty": relying_party,
147
+ "TokenType": "JWT",
148
+ "Properties": {
149
+ "UserTokens": [self.user_token.token],
150
+ "SandboxId": "RETAIL",
151
+ },
152
+ }
153
+
154
+ resp = await self.session.post(url, json=data, headers=headers)
155
+ if resp.status_code == 401: # if unauthorized
156
+ print(
157
+ "Failed to authorize you! Your password or username may be wrong or you are trying to use child account (< 18 years old)"
158
+ )
159
+ raise AuthenticationException()
160
+ resp.raise_for_status()
161
+ return XSTSResponse(**resp.json())