From 4825142e6a9ffd64b99c99c6ca7e12f4c7e0d30e Mon Sep 17 00:00:00 2001 From: misiektoja Date: Sat, 7 Feb 2026 04:29:07 +0100 Subject: [PATCH 1/3] fix(people): correct friends list API endpoints and contract version --- .../api/provider/people/__init__.py | 35 +++++++++++++------ src/pythonxbox/api/provider/people/models.py | 28 +++++++-------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/pythonxbox/api/provider/people/__init__.py b/src/pythonxbox/api/provider/people/__init__.py index 17e1799..6de6c3b 100644 --- a/src/pythonxbox/api/provider/people/__init__.py +++ b/src/pythonxbox/api/provider/people/__init__.py @@ -19,10 +19,17 @@ class PeopleProvider(RateLimitedProvider): SOCIAL_URL = "https://social.xboxlive.com" HEADERS_SOCIAL: ClassVar = {"x-xbl-contract-version": "2"} PEOPLE_URL = "https://peoplehub.xboxlive.com" - HEADERS_PEOPLE: ClassVar = { + # Contract v7 provides full relationship fields (isFriend, canBeFriended, etc), but only works for + # get_friends_own, not get_friends_by_xuid + HEADERS_PEOPLE_V7: ClassVar = { "x-xbl-contract-version": "7", "Accept-Language": "overwrite in __init__", } + # Contract v5 works for all endpoints including get_friends_by_xuid + HEADERS_PEOPLE_V5: ClassVar = { + "x-xbl-contract-version": "5", + "Accept-Language": "overwrite in __init__", + } SEPERATOR = "," # NOTE: Rate Limits are noted for social.xboxlive.com ONLY @@ -38,8 +45,12 @@ def __init__(self, client: "XboxLiveClient") -> None: client (:class:`XboxLiveClient`): Instance of client """ super().__init__(client) - self._headers = {**self.HEADERS_PEOPLE} - self._headers.update({"Accept-Language": self.client.language.locale}) + # Headers for endpoints that work with v7 (provides more fields) + self._headers_v7 = {**self.HEADERS_PEOPLE_V7} + self._headers_v7.update({"Accept-Language": self.client.language.locale}) + # Headers for endpoints that require v5 (get_friends_by_xuid) + self._headers_v5 = {**self.HEADERS_PEOPLE_V5} + self._headers_v5.update({"Accept-Language": self.client.language.locale}) async def get_friends_own( self, decoration_fields: list[PeopleDecoration] | None = None, **kwargs @@ -59,8 +70,8 @@ async def get_friends_own( ] decoration = self.SEPERATOR.join(decoration_fields) - url = f"{self.PEOPLE_URL}/users/me/people/friends/decoration/{decoration}" - resp = await self.client.session.get(url, headers=self._headers, **kwargs) + url = f"{self.PEOPLE_URL}/users/me/people/social/decoration/{decoration}" + resp = await self.client.session.get(url, headers=self._headers_v7, **kwargs) resp.raise_for_status() return PeopleResponse.model_validate_json(resp.text) @@ -71,7 +82,10 @@ async def get_friends_by_xuid( **kwargs, ) -> PeopleResponse: """ - Get friendlist of own profile + Get friendlist of a user by their XUID + + Args: + xuid: XUID of the user to get friends from Returns: :class:`PeopleResponse`: People Response @@ -85,8 +99,9 @@ async def get_friends_by_xuid( ] decoration = self.SEPERATOR.join(decoration_fields) - url = f"{self.PEOPLE_URL}/users/me/people/xuids({xuid})/decoration/{decoration}" - resp = await self.client.session.get(url, headers=self._headers, **kwargs) + url = f"{self.PEOPLE_URL}/users/xuid({xuid})/people/social/decoration/{decoration}" + # Use v5 headers - contract v7 returns empty people list for other users + resp = await self.client.session.get(url, headers=self._headers_v5, **kwargs) resp.raise_for_status() return PeopleResponse.model_validate_json(resp.text) @@ -116,7 +131,7 @@ async def get_friends_own_batch( url = f"{self.PEOPLE_URL}/users/me/people/batch/decoration/{decoration}" resp = await self.client.session.post( - url, json={"xuids": xuids}, headers=self._headers, **kwargs + url, json={"xuids": xuids}, headers=self._headers_v7, **kwargs ) resp.raise_for_status() return PeopleResponse.model_validate_json(resp.text) @@ -137,7 +152,7 @@ async def get_friend_recommendations( url = ( f"{self.PEOPLE_URL}/users/me/people/recommendations/decoration/{decoration}" ) - resp = await self.client.session.get(url, headers=self._headers, **kwargs) + resp = await self.client.session.get(url, headers=self._headers_v7, **kwargs) resp.raise_for_status() return PeopleResponse.model_validate_json(resp.text) diff --git a/src/pythonxbox/api/provider/people/models.py b/src/pythonxbox/api/provider/people/models.py index 7fd8224..3965f3c 100644 --- a/src/pythonxbox/api/provider/people/models.py +++ b/src/pythonxbox/api/provider/people/models.py @@ -117,22 +117,22 @@ class Detail(CamelCaseModel): is_verified: bool location: str | None = None tenure: str | None = None - watermarks: list[str] + watermarks: list[str] = Field(default_factory=list) blocked: bool mute: bool follower_count: int following_count: int has_game_pass: bool - can_be_friended: bool - can_be_followed: bool - is_friend: bool - friend_count: int - is_friend_request_received: bool - is_friend_request_sent: bool - is_friend_list_shared: bool - is_following_caller: bool - is_followed_by_caller: bool - is_favorite: bool + can_be_friended: bool | None = None + can_be_followed: bool | None = None + is_friend: bool | None = None + friend_count: int | None = None + is_friend_request_received: bool | None = None + is_friend_request_sent: bool | None = None + is_friend_list_shared: bool | None = None + is_following_caller: bool | None = None + is_followed_by_caller: bool | None = None + is_favorite: bool | None = None class SocialManager(CamelCaseModel): @@ -201,9 +201,9 @@ class Person(CamelCaseModel): preferred_flag: str preferred_platforms: list[Any] friended_date_time_utc: datetime | None = None - is_friend: bool - is_friend_request_received: bool - is_friend_request_sent: bool + is_friend: bool | None = None + is_friend_request_received: bool | None = None + is_friend_request_sent: bool | None = None class RecommendationSummary(CamelCaseModel): From 70a6f8d7218022df2ac0da40b684a0bb737d45c3 Mon Sep 17 00:00:00 2001 From: misiektoja Date: Sat, 7 Feb 2026 04:48:09 +0100 Subject: [PATCH 2/3] fix(people): revert get_friends_own URL to maintain established behavior --- src/pythonxbox/api/provider/people/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pythonxbox/api/provider/people/__init__.py b/src/pythonxbox/api/provider/people/__init__.py index 6de6c3b..c9933b5 100644 --- a/src/pythonxbox/api/provider/people/__init__.py +++ b/src/pythonxbox/api/provider/people/__init__.py @@ -70,7 +70,7 @@ async def get_friends_own( ] decoration = self.SEPERATOR.join(decoration_fields) - url = f"{self.PEOPLE_URL}/users/me/people/social/decoration/{decoration}" + url = f"{self.PEOPLE_URL}/users/me/people/friends/decoration/{decoration}" resp = await self.client.session.get(url, headers=self._headers_v7, **kwargs) resp.raise_for_status() return PeopleResponse.model_validate_json(resp.text) From 6243bc5f93856f346450db251f5518b3576434a0 Mon Sep 17 00:00:00 2001 From: misiektoja Date: Sat, 7 Feb 2026 13:19:12 +0100 Subject: [PATCH 3/3] test(people): add compatibility test for contract v5 response format --- .../responses/people_friends_by_xuid_v5.json | 74 +++++++++++++++++++ tests/test_people.py | 15 ++++ 2 files changed, 89 insertions(+) create mode 100644 tests/data/responses/people_friends_by_xuid_v5.json diff --git a/tests/data/responses/people_friends_by_xuid_v5.json b/tests/data/responses/people_friends_by_xuid_v5.json new file mode 100644 index 0000000..cde58a8 --- /dev/null +++ b/tests/data/responses/people_friends_by_xuid_v5.json @@ -0,0 +1,74 @@ +{ + "people": [ + { + "xuid": "2533274812261808", + "isFavorite": false, + "isFollowingCaller": false, + "isFollowedByCaller": true, + "isIdentityShared": false, + "addedDateTimeUtc": null, + "displayName": "VolekTheFNDwarf", + "realName": "", + "displayPicRaw": "https://images-eds-ssl.xboxlive.com/image?url=wHwbXKif8cus8csoZ03RW3apWESZjav65Yncai8aRmVbSlZ3zqRpg1sdxEje_JmFTQaIPE.QhWezFkHjetzbapvRXDLBSJ6wiY_cMsEgnqAxqpD1OrJZcC6lG1e2UMcpLmHsW8LHADcb1wfWVA4BUP6kQwwmBrbaYrX3XQiXI.8-&format=png", + "showUserAsAvatar": "1", + "gamertag": "VolekTheFNDwarf", + "gamerScore": "70700", + "modernGamertag": "VolekTheFNDwarf", + "modernGamertagSuffix": "", + "uniqueModernGamertag": "VolekTheFNDwarf", + "xboxOneRep": "GoodPlayer", + "presenceState": "Offline", + "presenceText": "Offline", + "presenceDevices": null, + "isBroadcasting": false, + "isCloaked": null, + "isQuarantined": false, + "isXbox360Gamerpic": false, + "lastSeenDateTimeUtc": null, + "suggestion": null, + "recommendation": null, + "search": null, + "titleHistory": null, + "multiplayerSummary": { + "inMultiplayerSession": 0, + "inParty": 0 + }, + "recentPlayer": null, + "follower": null, + "preferredColor": { + "primaryColor": "1081ca", + "secondaryColor": "10314f", + "tertiaryColor": "105080" + }, + "presenceDetails": [], + "titlePresence": null, + "titleSummaries": null, + "presenceTitleIds": null, + "detail": { + "accountTier": "Gold", + "bio": "Lover of nerd culture & those who embrace it. 🇺🇸 | 🎮 gamer & collector | 🎲 d&d | 💪 fitness | 💻 twitch | ⚔️cosplay twitch.tv/volek_the_fn_dwarf", + "isVerified": false, + "location": "Charlotte", + "tenure": "12", + "watermarks": [], + "blocked": false, + "mute": false, + "followerCount": 50, + "followingCount": 34, + "hasGamePass": false + }, + "communityManagerTitles": null, + "socialManager": null, + "broadcast": null, + "tournamentSummary": null, + "avatar": null, + "linkedAccounts": [], + "colorTheme": "gamerpicblur", + "preferredFlag": "", + "preferredPlatforms": [] + } + ], + "recommendationSummary": null, + "friendFinderState": null, + "accountLinkDetails": null +} diff --git a/tests/test_people.py b/tests/test_people.py index 5f84f7e..ce31315 100644 --- a/tests/test_people.py +++ b/tests/test_people.py @@ -32,6 +32,21 @@ async def test_people_friends_by_xuid( assert route.called +@pytest.mark.asyncio +async def test_people_by_xuid_v5( + respx_mock: MockRouter, xbl_client: XboxLiveClient +) -> None: + route = respx_mock.get("https://peoplehub.xboxlive.com").mock( + return_value=Response(200, json=get_response_json("people_friends_by_xuid_v5")) + ) + ret = await xbl_client.people.get_friends_by_xuid("2669321029139235") + + assert len(ret.people) == 1 + assert ret.people[0].gamertag == "VolekTheFNDwarf" + assert ret.people[0].is_friend is None # This field is missing in v5 + assert route.called + + @pytest.mark.asyncio async def test_profiles_batch( respx_mock: MockRouter, xbl_client: XboxLiveClient