From 25af27b7b4a7be9541be77164acbdc9b4bf0ce1e Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 03:39:22 -0400 Subject: [PATCH 01/44] Initial club implementation --- xbox/webapi/api/client.py | 2 + xbox/webapi/api/provider/clubs/__init__.py | 444 +++++++++++++++++++++ xbox/webapi/api/provider/clubs/const.py | 45 +++ xbox/webapi/api/provider/clubs/models.py | 325 +++++++++++++++ 4 files changed, 816 insertions(+) create mode 100644 xbox/webapi/api/provider/clubs/__init__.py create mode 100644 xbox/webapi/api/provider/clubs/const.py create mode 100644 xbox/webapi/api/provider/clubs/models.py diff --git a/xbox/webapi/api/client.py b/xbox/webapi/api/client.py index 6cf17ec5..0bef80da 100644 --- a/xbox/webapi/api/client.py +++ b/xbox/webapi/api/client.py @@ -14,6 +14,7 @@ from xbox.webapi.api.provider.account import AccountProvider from xbox.webapi.api.provider.achievements import AchievementsProvider from xbox.webapi.api.provider.catalog import CatalogProvider +from xbox.webapi.api.provider.clubs import ClubProvider from xbox.webapi.api.provider.cqs import CQSProvider from xbox.webapi.api.provider.gameclips import GameclipProvider from xbox.webapi.api.provider.lists import ListsProvider @@ -130,6 +131,7 @@ def __init__( self.account = AccountProvider(self) self.catalog = CatalogProvider(self) self.smartglass = SmartglassProvider(self) + self.clubs = ClubProvider(self) @property def xuid(self) -> str: diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py new file mode 100644 index 00000000..4ec9e53f --- /dev/null +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -0,0 +1,444 @@ +""" +Clubs + +Manage clubs and club information. + +TODO: Change club settings +TODO: Club messaging/activity feed +""" +from collections.abc import Sequence +from typing import Dict, List, Optional, Union +from uuid import UUID + +from xbox.webapi.api.provider.baseprovider import BaseProvider +from xbox.webapi.api.provider.clubs.models import ( + Club, + ClubPresence, + ClubReservation, + ClubRole, + ClubRootSettings, + ClubRoster, + ClubSummary, + ClubType, + ClubUserPresenceRecord, + GetPresenceResponse, + OwnedClubsResponse, + SearchClubsResponse, + SuggestedClubsResponse, + UpdateRolesResponse, +) + +_NULL_UUID = UUID(int=0) + + +class ClubProvider(BaseProvider): + CLUBACCOUNTS_URL = "https://clubaccounts.xboxlive.com" + CLUBHUB_URL = "https://clubhub.xboxlive.com" + CLUBPRESENCE_URL = "https://clubpresence.xboxlive.com" + CLUBROSTER_URL = "https://clubroster.xboxlive.com" + # CHATFD_URL = "https://chatfd.xboxlive.com" + # CLUBSEARCH_URL = 'https://clubsearch.xboxlive.com' + + HEADERS_CLUBACCOUNTS = {"x-xbl-contract-version": "1"} + HEADERS_OWNED_CLUBS = HEADERS_CLUBACCOUNTS | {"x-xbl-contract-version": "2"} + HEADERS_CLUBHUB = {"x-xbl-contract-version": "5", "Accept-Language": "en-US"} + HEADERS_CLUBPRESENCE = {"x-xbl-contract-version": "1"} + HEADERS_CLUBROSTER = {"x-xbl-contract-version": "4"} + # HEADERS_CHATFD = {"x-xbl-contract-version": "1"} + # HEADERS_CLUBSEARCH = {'x-xbl-contract-version': '2'} + + SEPARATOR = "," + + # CLUB ACCOUNTS + # --------------------------------------------------------------------------- + + async def get_club_summary( + self, club_id: str, actor: Optional[str] = None, **kwargs + ) -> ClubSummary: + """Get a summary of a given club's information. + + You must own the club to use this method. + + Codes + - 1021: The actor specified for the suspension record is not valid. + """ + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" + if actor: + url += f"/suspension/{actor}" + + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs + ) + resp.raise_for_status() + + return ClubSummary.parse_raw(await resp.text()) + + async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: + """Get list of clubs owned by the caller.""" + + url = self.CLUBACCOUNTS_URL + f"/users/xuid({self.client.xuid})/clubsowned" + + resp = await self.client.session.get( + url, headers=self.HEADERS_OWNED_CLUBS, **kwargs + ) + resp.raise_for_status() + + return OwnedClubsResponse.parse_raw(await resp.text()) + + async def claim_club(self, name: str, **kwargs) -> ClubReservation: + """Reserve a club name for use in create_club(). + + Codes + - 200: Successfully claimed club. + - 1007: The requested club name contains invalid characters. + Club names must only use letter, numbers, and spaces. + - 1010: The requested club name is not available. + - 1023: The requested club name was rejected. + """ + data = {"name": name} + + url = self.CLUBACCOUNTS_URL + f"/clubs/reserve" + + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + ) + resp.raise_for_status() + return ClubReservation.parse_raw(await resp.text()) + + async def create_club( + self, + name: str, + club_type: ClubType, + genre: str = "social", + title_family_id: UUID = _NULL_UUID, + **kwargs, + ) -> ClubSummary: + """Create a club with the given name and visibility. + + If creating a public club, you must first call claim_club() with the name you want to use. + + Codes + - 201: Successfully created club. + - 409: Another pending operation in progress. + - 1007: The requested club name contains invalid characters. + Club names must only use letter, numbers, and spaces. + - 1014: The club name has not been reserved by the calling user. + This happens when club_type is PUBLIC and you have not + called claim_club(). + - 1023: The requested club name was rejected. + - 1038: A TitleFamilyId value must be specified when requesting a TitleClub + (genre is "title" but title_family_id is not provided). + - 1041: The calling title is not authorized to perform the requested action with the requested TitleFamilyId + - 1042: The club genre is not valid. + """ + data = {"name": name, "type": club_type, "genre": genre} + if title_family_id.int: + data["titleFamilyId"] = str(title_family_id) + + url = self.CLUBACCOUNTS_URL + f"/clubs/create" + + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + ) + resp.raise_for_status() + + return ClubSummary.parse_raw(await resp.text()) + + async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: + """Rename a club with the given name. + + A club can only be renamed once. + + Codes + - 201: Successfully created club. + - 409: Another pending operation in progress. + - 1007: The requested club name contains invalid characters. + Club names must only use letter, numbers, and spaces. + - 1014: The club name has not been reserved by the calling user. + This happens when club_type is PUBLIC and you have not + called claim_club(). + - 1023: The requested club name was rejected. + - 1035: The name cannot be changed for the requested club. All available name changes have been used. + """ + data = {"method": "ChangeName", "name": name} + + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" + + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + ) + resp.raise_for_status() + + return ClubSummary.parse_raw(await resp.text()) + + async def delete_club( + self, club_id: str, actor: Optional[str] = None, **kwargs + ) -> bool: + """Delete the club with the given id. + + Codes + - 204: Successfully deleted club. + - 409: Another pending operation in progress. + """ + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" + if actor: + url += f"/suspension/{actor}" + + resp = await self.client.session.delete( + url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs + ) + resp.raise_for_status() + + return resp.status == 204 + + # CLUB HUB + # --------------------------------------------------------------------------- + + @staticmethod + def _create_search_params(query: str, **kwargs) -> Dict[str, str]: + if not query: + raise ValueError("Query must not be empty.") + + params = {"q": query} + + for key, arg in kwargs.items(): + if not isinstance(arg, str) and isinstance(arg, Sequence): + params[key] = ",".join(arg) + elif arg is not None: + params[key] = str(arg) + + return params + + def _create_clubhub_id_endpoint( + self, + ids: Union[str, List[str]], + is_xuid: bool = False, + decorations: Optional[List[str]] = None, + ) -> str: + if isinstance(ids, str): + ids = [ids] + + if decorations is None: + decorations = [] + + if len(ids) > 10: + raise ValueError("Endpoint has more ids than the supported maximum (10)") + + id_subpath = "Ids" if not is_xuid else "Xuid" + endpoint = self.CLUBHUB_URL + f"/clubs/{id_subpath}({self.SEPARATOR.join(ids)})" + if decorations: + endpoint += f"/decoration/{(','.join(decorations))}" + + return endpoint + + async def _send_clubhub_decoration_request( + self, club_ids: Union[str, List[str]], decorations: List[str], **kwargs + ) -> SearchClubsResponse: + + url = self._create_clubhub_id_endpoint(club_ids, decorations=decorations) + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBHUB, **kwargs + ) + resp.raise_for_status() + return SearchClubsResponse.parse_raw(await resp.text()) + + async def get_club( + self, club_id: str, decorations: Optional[List[str]] = None, **kwargs + ) -> Club: + """Get a club through its id.""" + return (await self.get_clubs([club_id], decorations, **kwargs))[0] + + async def get_clubs( + self, club_ids: List[str], decorations: Optional[List[str]] = None, **kwargs + ) -> List[Club]: + """Get club through their ids.""" + + if decorations is None: + decorations = [ + "detail", + "clubPresence", + f"roster(member moderator requestedToJoin banned recommended)", + "settings" + ] + + return [ + club for club in ( + await self._send_clubhub_decoration_request(club_ids, decorations=decorations, **kwargs) + ).clubs + ] + + async def get_club_associations( + self, xuid: Optional[str] = None, **kwargs + ) -> List[Club]: + """Get clubs associated with the given xuid.""" + xuid = xuid or self.client.xuid + + url = self._create_clubhub_id_endpoint(xuid, is_xuid=True) + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBHUB, **kwargs + ) + resp.raise_for_status() + return [club for club in SearchClubsResponse.parse_raw(await resp.text()).clubs] + + async def get_club_recommendations(self, **kwargs) -> List[Club]: + """Get clubs recommendations for the caller.""" + + url = self.CLUBHUB_URL + f"/clubs/recommendations" + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBHUB, **kwargs + ) + resp.raise_for_status() + + return [club for club in SearchClubsResponse.parse_raw(await resp.text()).clubs] + + async def search_clubs( + self, + query: str, + titles: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + count: Optional[int] = None, + **kwargs, + ) -> SearchClubsResponse: + params = self._create_search_params( + query, titles=titles, tags=tags, count=count + ) + + url = self.CLUBHUB_URL + f"/clubs/search" + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBHUB, params=params or None, **kwargs + ) + resp.raise_for_status() + return SearchClubsResponse.parse_raw(await resp.text()) + + # CLUB PRESENCE + # --------------------------------------------------------------------------- + + async def get_presence_counts(self, club_id: str, **kwargs) -> GetPresenceResponse: + url = self.CLUBPRESENCE_URL + f"/clubs/{club_id}/users/count" + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBPRESENCE, **kwargs + ) + resp.raise_for_status() + return GetPresenceResponse.parse_raw(await resp.text()) + + async def set_presence_within_club( + self, club_id: str, xuid: str, presence: ClubPresence, **kwargs + ) -> bool: + """Set your presence in a clubs to the given ClubPresence value. + + Codes: + - 204: Successfully changed presence. + - 1004: The claims are invalid. + """ + # Microsoft.Xbox.Services.dll --- xbox::services::clubs::clubs::set_presence_within_club + data = {"userPresenceState": presence} + + url = self.CLUBPRESENCE_URL + f"/clubs/{club_id}/users/xuid({xuid})" + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBPRESENCE, json=data, **kwargs + ) + resp.raise_for_status() + return resp.status == 204 + + # CLUB ROSTER + # --------------------------------------------------------------------------- + async def _update_users_club_roles( + self, club_id: str, xuid: str, advance: bool, **kwargs + ) -> UpdateRolesResponse: + """Add or remove a xuid from a club id. + + Codes + - 1013: Cannot remove owner from the clubs. + """ + url = self.CLUBROSTER_URL + f"/clubs/{club_id}/users/xuid({xuid})" + if advance: + method = self.client.session.put + else: + method = self.client.session.delete + + resp = await method(url, headers=self.HEADERS_CLUBROSTER, **kwargs) + + resp.raise_for_status() + return UpdateRolesResponse.parse_raw(await resp.text()) + + async def _set_users_club_roles( + self, club_id: str, xuid: str, role: ClubRole, add_role: bool, **kwargs + ) -> UpdateRolesResponse: + """Add or remove a club role from a xuid. + + Codes + - 1001: Caller role insufficient to perform the requested action. + - 1005: Contract version header was missing or invalid. + - 1008: Request payload was not understood by the service. + - 1011: Requested roles cannot be explicitly modified. + - 1012: Cannot modify ban status due to permissions or request format. + """ + data = {} + url = self.CLUBROSTER_URL + f"/clubs/{club_id}/users/xuid({xuid})/roles" + if add_role: + method = self.client.session.post + data["roles"] = [role] + else: + method = self.client.session.delete + url += f"/{role}" + + resp = await method(url, headers=self.HEADERS_CLUBROSTER, json=data, **kwargs) + + resp.raise_for_status() + return UpdateRolesResponse.parse_raw(await resp.text()) + + async def add_user_to_club( + self, club_id: str, xuid: Optional[str] = None, **kwargs + ) -> UpdateRolesResponse: + xuid = xuid or self.client.xuid + + return await self._update_users_club_roles( + club_id, xuid, advance=True, **kwargs + ) + + async def remove_user_from_club( + self, club_id: str, xuid: Optional[str] = None, **kwargs + ) -> UpdateRolesResponse: + xuid = xuid or self.client.xuid + + return await self._update_users_club_roles( + club_id, xuid, advance=False, **kwargs + ) + + async def follow_club(self, club_id: str, **kwargs) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, self.client.xuid, ClubRole.FOLLOWER, True, **kwargs + ) + + async def unfollow_club(self, club_id: str, **kwargs) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, self.client.xuid, ClubRole.FOLLOWER, False, **kwargs + ) + + async def ban_user_from_club( + self, club_id: str, xuid: str, **kwargs + ) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, xuid, ClubRole.BANNED, True, **kwargs + ) + + async def unban_user_from_club( + self, club_id: str, xuid: str, **kwargs + ) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, xuid, ClubRole.BANNED, False, **kwargs + ) + + async def add_club_moderator( + self, club_id: str, xuid: str, **kwargs + ) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, xuid, ClubRole.MODERATOR, True, **kwargs + ) + + async def remove_club_moderator( + self, club_id: str, xuid: str, **kwargs + ) -> UpdateRolesResponse: + return await self._set_users_club_roles( + club_id, xuid, ClubRole.MODERATOR, False, **kwargs + ) diff --git a/xbox/webapi/api/provider/clubs/const.py b/xbox/webapi/api/provider/clubs/const.py new file mode 100644 index 00000000..819adfa4 --- /dev/null +++ b/xbox/webapi/api/provider/clubs/const.py @@ -0,0 +1,45 @@ +"""Web API Constants.""" + +from typing import Final + +COMMUNICATION_TAGS: Final[frozenset] = frozenset( + ( + "kidfriendlycontentonly", + "allcontentok", + "textchatrequired", + "nomic", + "micoptional", + "micrequired", + "notrashtalking", + "trashtalkingok", + "noswearing", + "swearingok", + ) +) + +PLAY_STYLE_TAGS: Final[frozenset] = frozenset( + ( + "newplayerswelcome", + "willhelpnewplayers", + "experiencedplayersonly", + "casual", + "competitive", + "cooperative", + "playervsplayer", + "achievementhunting", + "tournament", + ) +) + +PEOPLE_TAGS: Final[frozenset] = frozenset( + ( + "adultsonly", + "allages", + "everyoneiswelcome", + ) +) + + +CLUB_TAGS: Final[frozenset] = frozenset.union( + COMMUNICATION_TAGS, PLAY_STYLE_TAGS, PEOPLE_TAGS +) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py new file mode 100644 index 00000000..5da1dd2b --- /dev/null +++ b/xbox/webapi/api/provider/clubs/models.py @@ -0,0 +1,325 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Generic, List, Optional, TypeVar +from uuid import UUID + +from xbox.webapi.common.models import CamelCaseModel + + +class ClubType(str, Enum): + # From xbox::services::clubs::clubs_service_impl::convert_club_type_to_string + # In Microsoft.Xbox.Services.dll + UNKNOWN = "unknown" # Unknown club type + PUBLIC = "open" # Open club + PRIVATE = "closed" # Closed club + HIDDEN = "secret" # Secret club + + +class ClubRole(str, Enum): + # From xbox::services::clubs::clubs_service_impl::convert_club_role_to_string + # In Microsoft.Xbox.Services.dll + NONMEMBER = "Nonmember" # Not a member of the club. Used exclusively for permissions/settings + MEMBER = "Member" # Member of a club + MODERATOR = "Moderator" # Moderator of a club + OWNER = "Owner" # Owner of a club + REQUESTED_TO_JOIN = "RequestedToJoin" # User has requested to join a club + RECOMMENDED = "Recommended" # User has been recommended for a club + INVITED = "Invited" # User has been invited to a club + BANNED = "Banned" # User has been banned from all interaction with a club. + # A user cannot have any other role with a club if they are banned from it + FOLLOWER = "Follower" # Follower of a club + + +class ClubPresence(str, Enum): + # From xbox::services::clubs::clubs_service_impl::convert_user_presence_to_string + # In Microsoft.Xbox.Services.dll + NOT_IN_CLUB = "NotInClub" # User is no longer on a club page. + IN_CLUB = "InClub" # User is viewing the club, but not on any specific page. + CHAT = "Chat" # User is on the chat page. + FEED = "Feed" # User is viewing the club feed. + ROSTER = "Roster" # User is viewing the club roster/presence. + PLAY = ( + "Play" # User is on the play tab in the club (not actually playing anything). + ) + IN_GAME = "InGame" # User is playing the associated game. + + # Extra value in modern club implementation + IN_PARTY = "InParty" # UNDOCUMENTED -- UNCONFIRMED ENUM VALUE + + +class PreferredColor(CamelCaseModel): + primary_color: Optional[str] + secondary_color: Optional[str] + tertiary_color: Optional[str] + + +class DeepLink(CamelCaseModel): + page_name: str + uri: str + + +class DeepLinks(CamelCaseModel): + xbox: Optional[List[DeepLink]] + pc: Optional[List[DeepLink]] + iOS: Optional[List[DeepLink]] + android: Optional[List[DeepLink]] + + +class ClubSearchFacetResult(CamelCaseModel): + count: int + value: str + + +class ClubSearchFacetResults(CamelCaseModel): + titles: Optional[List[ClubSearchFacetResult]] + tags: Optional[List[ClubSearchFacetResult]] + + +class ClubsSuggestResult(CamelCaseModel): + title_family_id: UUID + score: int + languages: List[str] + language_regions: List[str] + preferred_color: Optional[PreferredColor] + tags: List[str] + titles: List[str] + title_tags: List[str] + short_name: Optional[str] + name: str + id: str + display_image_url: str + description: str + + +class ClubsSuggestResultWithText(CamelCaseModel): + result: ClubsSuggestResult + text: str + + +class ClubTypeContainer(CamelCaseModel): + type: ClubType + genre: str + localized_title_family_name: Optional[str] + title_family_id: UUID + + +class ClubRoleRecord(CamelCaseModel): + actor_xuid: str # Actor Xuid that was responsible for user belonging to the role. + xuid: str # Xuid that belongs to the role. + created_date: datetime # When the user was added to the role. + localized_role: Optional[ClubRole] # Role of the user. + + +class ClubRoster(CamelCaseModel): + moderator: List[ClubRoleRecord] # Club moderators + requested_to_join: Optional[ + List[ClubRoleRecord] + ] # Users who've requested to join the club + recommended: Optional[ + List[ClubRoleRecord] + ] # Users who've been recommended for the club + banned: Optional[List[ClubRoleRecord]] # Users who've been banned from the club + + +class ClubRecommendationReason(CamelCaseModel): + localized_text: str # Localized string giving the reason the club is recommended + + +class ClubRecommendation(CamelCaseModel): + reasons: List[ClubRecommendationReason] + criteria: str + title_ids: List[str] + + +class ClubReservation(CamelCaseModel): + name: str + owner: str + expires: datetime + + +class ClubSummary(CamelCaseModel): + name: str + owner: str + id: str + type: ClubType + created: datetime + can_delete_immediately: bool + suspension_required_after: datetime + genre: str + + +class ClubUserPresenceRecord(CamelCaseModel): + xuid: str # Xuid of the user who was present at the club. + last_seen_timestamp: datetime # Time when the user was last present within the club. + last_seen_state: ClubPresence # User's state when they were last seen. + + +_VT = TypeVar("_VT") # Setting Value Generic + + +class Setting(CamelCaseModel, Generic[_VT]): + value: _VT + allowed_values: Optional[List[_VT]] + can_viewer_change_setting: bool + + +class ClubActionSetting(Setting[str]): + can_viewer_act: bool + allowed_values: List[str] + + +class ClubFeedSettings(CamelCaseModel): + post: ClubActionSetting + pin_post: ClubActionSetting + post_media_from_device: ClubActionSetting + post_media_from_xbl_library: ClubActionSetting + post_store_link: ClubActionSetting + post_web_link: ClubActionSetting + schedule_post: ClubActionSetting + view: ClubActionSetting + + +class ClubChatSettings(CamelCaseModel): + write: ClubActionSetting + set_chat_topic: ClubActionSetting + view: ClubActionSetting + + +class ClubLfgSettings(CamelCaseModel): + join: ClubActionSetting + create: ClubActionSetting + view: ClubActionSetting + + +class ClubRosterSettings(CamelCaseModel): + invite_or_accept: ClubActionSetting + kick_or_ban: ClubActionSetting + view: ClubActionSetting + joinability: ClubActionSetting + + +class ClubProfileSettings(CamelCaseModel): + update: ClubActionSetting + delete: ClubActionSetting + view: ClubActionSetting + view_analytics: ClubActionSetting + + +class ClubViewerRoleSettings(CamelCaseModel): + roles: List[ClubRole] + localized_role: Optional[Any] + + +class ClubRootSettings(CamelCaseModel): + feed: ClubFeedSettings + chat: ClubChatSettings + lfg: ClubLfgSettings + roster: ClubRosterSettings + profile: ClubProfileSettings + viewer_roles: ClubViewerRoleSettings + + +class ClubProfile(CamelCaseModel): + description: Setting[Optional[str]] # Description of the club + rules: Setting[Any] + name: Setting[str] # Name of the club + short_name: Setting[str] # Club short name + is_searchable: Setting[bool] # Should the club show up in search results + is_recommendable: Setting[bool] # Should the club show up in recommendations + leave_enabled: Setting[bool] # Can users leave the club + transfer_ownership_enabled: Setting[ + bool + ] # Can ownership of the club be transferred + mature_content_enabled: Setting[bool] # Is mature content enabled within the club + watch_club_titles_only: Setting[bool] + display_image_url: Setting[str] # URL for display image + background_image_url: Setting[str] # URL for background image + preferred_locale: Setting[str] # The club's preferred locale + tags: Setting[ + List[str] + ] # Tags associated with the club (ex. "Hate-Free", "Women only") + associated_titles: Setting[List[str]] # List of titles associated with the club + primary_color: Setting[str] # Primary color of the club + secondary_color: Setting[str] # Secondary color of the club + tertiary_color: Setting[str] # Tertiary color of the club + + +class Club(CamelCaseModel): + id: str # ClubId + club_type: ClubTypeContainer # Type (visibility) of club + creation_date_utc: datetime # When the club was created. + glyph_image_url: Optional[str] # Club's display image url + banner_image_url: Optional[str] # Club's background image url + settings: Optional[ + ClubRootSettings + ] # Settings dictating what actions users can take + # within the club depending on their role. + followers_count: int # Number of followers of the club. + members_count: int # Number of club members. + moderators_count: int # Number of club moderators. + recommended_count: int # Configurable club attributes + requested_to_join_count: int # Number of users requesting to join the club. + club_presence_count: int # Count of members present in the club. + club_presence_today_count: int # Count of members present in the club. + club_presence_in_game_count: int + roster: Optional[ClubRoster] + target_roles: Optional[Any] + recommendation: Optional[ClubRecommendation] + club_presence: Optional[List[ClubUserPresenceRecord]] + state: str + suspended_until_utc: Optional[ + datetime + ] # When the club remains suspended until. Null if not suspended + report_count: int # Number of reports for the club. + reported_items_count: int # Number of reported items for the club. + max_members_per_club: int + max_members_in_game: int + owner_xuid: Optional[str] + founder_xuid: str # Club founder's Xuid. + title_deep_links: Optional[DeepLinks] + profile: ClubProfile # Configurable club attributes + is_official_club: bool + club_deep_links: Optional[DeepLinks] + + +class OwnedClubsResponse(CamelCaseModel): + owner: str + clubs: Optional[List[ClubSummary]] + remaining_open_and_closed_clubs: str + remaining_secret_clubs: str + maximum_open_and_closed_clubs: str + maximum_secret_clubs: str + + +class SearchClubsResponse(CamelCaseModel): + clubs: List[Club] # List of clubs that match the search query + search_facet_results: Optional[ + ClubSearchFacetResults + ] # Facets can be used to further narrow down search results. + # The return map maps a facet (ie. tag or title) to a collection of search facet result objects. + # A search facet result object describes how often a particular value of that facet occurred. + recommendation_counts: Optional[Any] + club_deep_links: Optional[DeepLinks] + + +class SuggestedClubsResponse(CamelCaseModel): + results: List[ClubsSuggestResultWithText] + + +class UpdateRolesResponse(CamelCaseModel): + user_id: str + roles: List[ClubRole] + channel_follow_quota_max: Optional[int] + channel_follow_quota_remaining: Optional[int] + follow_quota_max: Optional[int] + follow_quota_remaining: Optional[int] + member_quota_max: Optional[int] + member_quota_remaining: Optional[int] + + +class GetPresenceResponse(CamelCaseModel): + club_id: str + total_count: int + active_count: int + here_today_count: int + in_game_count: int From d8774422f34c7d7cf09b664b09d0c8f292a14d89 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 05:27:06 -0400 Subject: [PATCH 02/44] fix formatting --- xbox/webapi/api/provider/clubs/__init__.py | 11 +++++++---- xbox/webapi/api/provider/clubs/const.py | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 4ec9e53f..9a0efc6f 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -257,13 +257,16 @@ async def get_clubs( decorations = [ "detail", "clubPresence", - f"roster(member moderator requestedToJoin banned recommended)", - "settings" + "roster(member moderator requestedToJoin banned recommended)", + "settings", ] return [ - club for club in ( - await self._send_clubhub_decoration_request(club_ids, decorations=decorations, **kwargs) + club + for club in ( + await self._send_clubhub_decoration_request( + club_ids, decorations=decorations, **kwargs + ) ).clubs ] diff --git a/xbox/webapi/api/provider/clubs/const.py b/xbox/webapi/api/provider/clubs/const.py index 819adfa4..01bfcde5 100644 --- a/xbox/webapi/api/provider/clubs/const.py +++ b/xbox/webapi/api/provider/clubs/const.py @@ -39,7 +39,6 @@ ) ) - CLUB_TAGS: Final[frozenset] = frozenset.union( COMMUNICATION_TAGS, PLAY_STYLE_TAGS, PEOPLE_TAGS ) From eb34b9dc96545bde2fbe1a24744374afe3ca4063 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 06:32:47 -0400 Subject: [PATCH 03/44] remove await method call for response texts --- xbox/webapi/api/provider/clubs/__init__.py | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 9a0efc6f..c70ac774 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -71,7 +71,7 @@ async def get_club_summary( ) resp.raise_for_status() - return ClubSummary.parse_raw(await resp.text()) + return ClubSummary.parse_raw(resp.text) async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: """Get list of clubs owned by the caller.""" @@ -83,7 +83,7 @@ async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: ) resp.raise_for_status() - return OwnedClubsResponse.parse_raw(await resp.text()) + return OwnedClubsResponse.parse_raw(resp.text) async def claim_club(self, name: str, **kwargs) -> ClubReservation: """Reserve a club name for use in create_club(). @@ -103,7 +103,7 @@ async def claim_club(self, name: str, **kwargs) -> ClubReservation: url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs ) resp.raise_for_status() - return ClubReservation.parse_raw(await resp.text()) + return ClubReservation.parse_raw(resp.text) async def create_club( self, @@ -142,7 +142,7 @@ async def create_club( ) resp.raise_for_status() - return ClubSummary.parse_raw(await resp.text()) + return ClubSummary.parse_raw(resp.text) async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: """Rename a club with the given name. @@ -169,7 +169,7 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: ) resp.raise_for_status() - return ClubSummary.parse_raw(await resp.text()) + return ClubSummary.parse_raw(resp.text) async def delete_club( self, club_id: str, actor: Optional[str] = None, **kwargs @@ -240,7 +240,7 @@ async def _send_clubhub_decoration_request( url, headers=self.HEADERS_CLUBHUB, **kwargs ) resp.raise_for_status() - return SearchClubsResponse.parse_raw(await resp.text()) + return SearchClubsResponse.parse_raw(resp.text) async def get_club( self, club_id: str, decorations: Optional[List[str]] = None, **kwargs @@ -281,7 +281,7 @@ async def get_club_associations( url, headers=self.HEADERS_CLUBHUB, **kwargs ) resp.raise_for_status() - return [club for club in SearchClubsResponse.parse_raw(await resp.text()).clubs] + return [club for club in SearchClubsResponse.parse_raw(resp.text).clubs] async def get_club_recommendations(self, **kwargs) -> List[Club]: """Get clubs recommendations for the caller.""" @@ -292,7 +292,7 @@ async def get_club_recommendations(self, **kwargs) -> List[Club]: ) resp.raise_for_status() - return [club for club in SearchClubsResponse.parse_raw(await resp.text()).clubs] + return [club for club in SearchClubsResponse.parse_raw(resp.text).clubs] async def search_clubs( self, @@ -311,7 +311,7 @@ async def search_clubs( url, headers=self.HEADERS_CLUBHUB, params=params or None, **kwargs ) resp.raise_for_status() - return SearchClubsResponse.parse_raw(await resp.text()) + return SearchClubsResponse.parse_raw(resp.text) # CLUB PRESENCE # --------------------------------------------------------------------------- @@ -322,7 +322,7 @@ async def get_presence_counts(self, club_id: str, **kwargs) -> GetPresenceRespon url, headers=self.HEADERS_CLUBPRESENCE, **kwargs ) resp.raise_for_status() - return GetPresenceResponse.parse_raw(await resp.text()) + return GetPresenceResponse.parse_raw(resp.text) async def set_presence_within_club( self, club_id: str, xuid: str, presence: ClubPresence, **kwargs @@ -362,7 +362,7 @@ async def _update_users_club_roles( resp = await method(url, headers=self.HEADERS_CLUBROSTER, **kwargs) resp.raise_for_status() - return UpdateRolesResponse.parse_raw(await resp.text()) + return UpdateRolesResponse.parse_raw(resp.text) async def _set_users_club_roles( self, club_id: str, xuid: str, role: ClubRole, add_role: bool, **kwargs @@ -388,7 +388,7 @@ async def _set_users_club_roles( resp = await method(url, headers=self.HEADERS_CLUBROSTER, json=data, **kwargs) resp.raise_for_status() - return UpdateRolesResponse.parse_raw(await resp.text()) + return UpdateRolesResponse.parse_raw(resp.text) async def add_user_to_club( self, club_id: str, xuid: Optional[str] = None, **kwargs From 958ff37763689c638b3bd2e8811634da3de71280 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 06:34:48 -0400 Subject: [PATCH 04/44] Fix ClubSummary fields --- xbox/webapi/api/provider/clubs/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 5da1dd2b..900260e7 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -143,8 +143,9 @@ class ClubSummary(CamelCaseModel): id: str type: ClubType created: datetime + free_name_change: Optional[bool] can_delete_immediately: bool - suspension_required_after: datetime + suspension_required_after: Optional[datetime] genre: str From 014cfd5c122d71f3289dbc653462f34374f0e52b Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 06:35:20 -0400 Subject: [PATCH 05/44] Add clubprofile.xboxlive.com functionality --- xbox/webapi/api/provider/clubs/__init__.py | 49 +++++++++++++++++++++- xbox/webapi/api/provider/clubs/models.py | 36 ++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index c70ac774..66cae9d9 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -3,10 +3,10 @@ Manage clubs and club information. -TODO: Change club settings TODO: Club messaging/activity feed """ from collections.abc import Sequence +import json from typing import Dict, List, Optional, Union from uuid import UUID @@ -18,6 +18,7 @@ ClubRole, ClubRootSettings, ClubRoster, + ClubSettingsContract, ClubSummary, ClubType, ClubUserPresenceRecord, @@ -27,6 +28,7 @@ SuggestedClubsResponse, UpdateRolesResponse, ) +from xbox.webapi.common.models import to_pascal _NULL_UUID = UUID(int=0) @@ -35,6 +37,7 @@ class ClubProvider(BaseProvider): CLUBACCOUNTS_URL = "https://clubaccounts.xboxlive.com" CLUBHUB_URL = "https://clubhub.xboxlive.com" CLUBPRESENCE_URL = "https://clubpresence.xboxlive.com" + CLUBPROFILE_URL = "https://clubprofile.xboxlive.com" CLUBROSTER_URL = "https://clubroster.xboxlive.com" # CHATFD_URL = "https://chatfd.xboxlive.com" # CLUBSEARCH_URL = 'https://clubsearch.xboxlive.com' @@ -43,6 +46,7 @@ class ClubProvider(BaseProvider): HEADERS_OWNED_CLUBS = HEADERS_CLUBACCOUNTS | {"x-xbl-contract-version": "2"} HEADERS_CLUBHUB = {"x-xbl-contract-version": "5", "Accept-Language": "en-US"} HEADERS_CLUBPRESENCE = {"x-xbl-contract-version": "1"} + HEADERS_CLUBPROFILE = {"x-xbl-contract-version": "2"} HEADERS_CLUBROSTER = {"x-xbl-contract-version": "4"} # HEADERS_CHATFD = {"x-xbl-contract-version": "1"} # HEADERS_CLUBSEARCH = {'x-xbl-contract-version': '2'} @@ -343,6 +347,49 @@ async def set_presence_within_club( resp.raise_for_status() return resp.status == 204 + # CLUB PROFILE + # --------------------------------------------------------------------------- + async def update_club_profile(self, club_id: str, **kwargs) -> None: + """Update club profile settings. + + Settings are passed in as kwarg pairs. Each setting name must be a valid ClubSettingsContract field. + All kwargs that fail are passed in as an HTTP kwarg. + + Codes + - 413: Description is too large (500 char max). + - 1100: Insufficient permissions for write request. + """ + contract = ClubSettingsContract.parse_obj( + {"creationDateUtc": "0001-01-01T00:00:00.000Z"} + ) + modified_fields = [] + + for key in kwargs.keys(): + # Skip if not valid setting name. + if key not in ClubSettingsContract.__fields_set__: + continue + + value = kwargs.pop(key) + + # Update contract fields with new values. + # If a value is None, omit it in the contract. + if value is not None: + setattr(contract, key, value) + + # Ensure modifiedFields are PascalCase. + modified_fields.append(to_pascal(key)) + + data = { + "requestContract": json.loads(contract.json()), + "modifiedFields": modified_fields, + } + + url = self.CLUBPROFILE_URL + f"/clubs/{club_id}/profile" + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBPROFILE, json=data, **kwargs + ) + resp.raise_for_status() + # CLUB ROSTER # --------------------------------------------------------------------------- async def _update_users_club_roles( diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 900260e7..368ae755 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -47,6 +47,12 @@ class ClubPresence(str, Enum): IN_PARTY = "InParty" # UNDOCUMENTED -- UNCONFIRMED ENUM VALUE +class ClubJoinability(str, Enum): + UNKNOWN = "Unknown" + REQUEST_TO_JOIN = "OpenJoin" + INVITE_ONLY = "InviteOnly" + + class PreferredColor(CamelCaseModel): primary_color: Optional[str] secondary_color: Optional[str] @@ -65,6 +71,36 @@ class DeepLinks(CamelCaseModel): android: Optional[List[DeepLink]] +class ClubSettingsContract(CamelCaseModel): + description: Optional[str] + creation_date_utc: datetime + background_image_url: Optional[str] + display_image_url: Optional[str] + preferred_color: Optional[PreferredColor] + activity_feed_enabled: Optional[bool] + chat_enabled: Optional[bool] + lfg_enabled: Optional[bool] + preferred_locale: Optional[str] + request_to_join_enabled: Optional[bool] + leave_enabled: Optional[bool] + transfer_ownership_enabled: Optional[bool] + is_promoted_club: Optional[bool] + tags: Optional[List[str]] + titles: Optional[List[str]] + who_can_post_to_feed: Optional[ClubRole] + who_can_invite: Optional[ClubRole] + who_can_chat: Optional[ClubRole] + who_can_create_lfg: Optional[ClubRole] + who_can_join_lfg: Optional[ClubRole] + mature_content_enabled: Optional[bool] + watch_club_titles_only: Optional[bool] + get_recommendation_enabled: Optional[bool] + search_enabled: Optional[bool] + delete_enabled: Optional[bool] + rename_enabled: Optional[bool] + joinability: Optional[ClubJoinability] + + class ClubSearchFacetResult(CamelCaseModel): count: int value: str From cd89d8e3e1b2ceeabb750fa60acf5e1e3950f802 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 07:02:01 -0400 Subject: [PATCH 06/44] TargetRolesRecords model --- xbox/webapi/api/provider/clubs/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 368ae755..5553afc2 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -157,6 +157,11 @@ class ClubRoster(CamelCaseModel): banned: Optional[List[ClubRoleRecord]] # Users who've been banned from the club +class TargetRoleRecords(CamelCaseModel): + roles: Optional[List[ClubRoleRecord]] + localized_role: Optional[ClubRoleRecord] + + class ClubRecommendationReason(CamelCaseModel): localized_text: str # Localized string giving the reason the club is recommended @@ -300,7 +305,7 @@ class Club(CamelCaseModel): club_presence_today_count: int # Count of members present in the club. club_presence_in_game_count: int roster: Optional[ClubRoster] - target_roles: Optional[Any] + target_roles: Optional[TargetRoleRecords] recommendation: Optional[ClubRecommendation] club_presence: Optional[List[ClubUserPresenceRecord]] state: str From 1af565b87a5ae4dc3b0d659170fa1b81bb91d146 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 21:12:41 -0400 Subject: [PATCH 07/44] Change ClubRoleRecord to work with TargetRolesRecords and get_club_associations() --- xbox/webapi/api/provider/clubs/__init__.py | 4 +++- xbox/webapi/api/provider/clubs/models.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 66cae9d9..57141767 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -280,7 +280,9 @@ async def get_club_associations( """Get clubs associated with the given xuid.""" xuid = xuid or self.client.xuid - url = self._create_clubhub_id_endpoint(xuid, is_xuid=True) + url = self._create_clubhub_id_endpoint( + xuid, is_xuid=True, decorations=["detail"] + ) resp = await self.client.session.get( url, headers=self.HEADERS_CLUBHUB, **kwargs ) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 5553afc2..0c5bd34d 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -141,7 +141,8 @@ class ClubTypeContainer(CamelCaseModel): class ClubRoleRecord(CamelCaseModel): actor_xuid: str # Actor Xuid that was responsible for user belonging to the role. - xuid: str # Xuid that belongs to the role. + xuid: Optional[str] # Xuid that belongs to the role. Empty if same as actor_xuid. + role: Optional[ClubRole] created_date: datetime # When the user was added to the role. localized_role: Optional[ClubRole] # Role of the user. From c1504503e72d6d564babe8b4d9371684205f321c Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 21:26:14 -0400 Subject: [PATCH 08/44] transfer_club_ownership() --- xbox/webapi/api/provider/clubs/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 57141767..4e9bb2c0 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -148,6 +148,25 @@ async def create_club( return ClubSummary.parse_raw(resp.text) + async def transfer_club_ownership( + self, club_id: str, xuid: str, **kwargs + ) -> ClubSummary: + """Transfer club ownership to the given xuid. + + Codes + - 1015: The requested club is not available. + """ + data = {"method": "TransferOwnership", "user": xuid} + + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" + + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + ) + resp.raise_for_status() + + return ClubSummary.parse_raw(resp.text) + async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: """Rename a club with the given name. From e18457a730385e31eaba07b2c71cc4c6776e0b4f Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 22:25:05 -0400 Subject: [PATCH 09/44] fix update_club_profile() --- xbox/webapi/api/provider/clubs/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 4e9bb2c0..87680d48 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -370,7 +370,7 @@ async def set_presence_within_club( # CLUB PROFILE # --------------------------------------------------------------------------- - async def update_club_profile(self, club_id: str, **kwargs) -> None: + async def update_club_profile(self, club_id: str, **setting_values) -> None: """Update club profile settings. Settings are passed in as kwarg pairs. Each setting name must be a valid ClubSettingsContract field. @@ -384,14 +384,14 @@ async def update_club_profile(self, club_id: str, **kwargs) -> None: {"creationDateUtc": "0001-01-01T00:00:00.000Z"} ) modified_fields = [] + request_kwargs = {} - for key in kwargs.keys(): + for key, value in setting_values.items(): # Skip if not valid setting name. - if key not in ClubSettingsContract.__fields_set__: + if key not in ClubSettingsContract.__fields__: + request_kwargs[key] = value continue - value = kwargs.pop(key) - # Update contract fields with new values. # If a value is None, omit it in the contract. if value is not None: @@ -407,7 +407,7 @@ async def update_club_profile(self, club_id: str, **kwargs) -> None: url = self.CLUBPROFILE_URL + f"/clubs/{club_id}/profile" resp = await self.client.session.post( - url, headers=self.HEADERS_CLUBPROFILE, json=data, **kwargs + url, headers=self.HEADERS_CLUBPROFILE, json=data, **request_kwargs ) resp.raise_for_status() From b92283a05d441ab2598cd3b819559373502ccf33 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 22:26:16 -0400 Subject: [PATCH 10/44] ClubState Enum --- xbox/webapi/api/provider/clubs/models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 0c5bd34d..987a4034 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -53,6 +53,11 @@ class ClubJoinability(str, Enum): INVITE_ONLY = "InviteOnly" +class ClubState(str, Enum): + NONE = "None" + SUSPENDED = "Suspended" + + class PreferredColor(CamelCaseModel): primary_color: Optional[str] secondary_color: Optional[str] @@ -309,7 +314,7 @@ class Club(CamelCaseModel): target_roles: Optional[TargetRoleRecords] recommendation: Optional[ClubRecommendation] club_presence: Optional[List[ClubUserPresenceRecord]] - state: str + state: ClubState suspended_until_utc: Optional[ datetime ] # When the club remains suspended until. Null if not suspended From f484bf30c2b5caaf64ac630d33551b17a8eaa626 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 22:26:37 -0400 Subject: [PATCH 11/44] fix ClubRoster to work with suspended clubs --- xbox/webapi/api/provider/clubs/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 987a4034..0e5ec59d 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -153,14 +153,16 @@ class ClubRoleRecord(CamelCaseModel): class ClubRoster(CamelCaseModel): - moderator: List[ClubRoleRecord] # Club moderators + moderator: Optional[ + List[ClubRoleRecord] + ] # Club moderators, only empty if club is suspended. requested_to_join: Optional[ List[ClubRoleRecord] - ] # Users who've requested to join the club + ] # Users who've requested to join the club. recommended: Optional[ List[ClubRoleRecord] - ] # Users who've been recommended for the club - banned: Optional[List[ClubRoleRecord]] # Users who've been banned from the club + ] # Users who've been recommended for the club. + banned: Optional[List[ClubRoleRecord]] # Users who've been banned from the club. class TargetRoleRecords(CamelCaseModel): From 377a05be7165425ff5a1c78f1814bc41a377acf0 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 22:27:42 -0400 Subject: [PATCH 12/44] rename claim_club to claim_club_name --- xbox/webapi/api/provider/clubs/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 87680d48..7748ff00 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -89,11 +89,12 @@ async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: return OwnedClubsResponse.parse_raw(resp.text) - async def claim_club(self, name: str, **kwargs) -> ClubReservation: + async def claim_club_name(self, name: str, **kwargs) -> ClubReservation: """Reserve a club name for use in create_club(). Codes - 200: Successfully claimed club. + - 1000: A parallel write operation took precedence over your request. - 1007: The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - 1010: The requested club name is not available. @@ -119,7 +120,7 @@ async def create_club( ) -> ClubSummary: """Create a club with the given name and visibility. - If creating a public club, you must first call claim_club() with the name you want to use. + If creating a public club, you must first call claim_club_name() with the name you want to use. Codes - 201: Successfully created club. @@ -127,8 +128,8 @@ async def create_club( - 1007: The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - 1014: The club name has not been reserved by the calling user. - This happens when club_type is PUBLIC and you have not - called claim_club(). + This happens when club_type is not HIDDEN and you have not + called claim_club_name(). - 1023: The requested club name was rejected. - 1038: A TitleFamilyId value must be specified when requesting a TitleClub (genre is "title" but title_family_id is not provided). @@ -178,8 +179,8 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: - 1007: The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - 1014: The club name has not been reserved by the calling user. - This happens when club_type is PUBLIC and you have not - called claim_club(). + This happens when club_type is not HIDDEN and you have not + called claim_club_name(). - 1023: The requested club name was rejected. - 1035: The name cannot be changed for the requested club. All available name changes have been used. """ From 13a2eea57e48027789764ce17ed008c1a4479e7d Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Tue, 27 Jun 2023 23:21:05 -0400 Subject: [PATCH 13/44] ClubSettingsContract creation_date_utc default --- xbox/webapi/api/provider/clubs/__init__.py | 4 +--- xbox/webapi/api/provider/clubs/models.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 7748ff00..2f4998f8 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -381,9 +381,7 @@ async def update_club_profile(self, club_id: str, **setting_values) -> None: - 413: Description is too large (500 char max). - 1100: Insufficient permissions for write request. """ - contract = ClubSettingsContract.parse_obj( - {"creationDateUtc": "0001-01-01T00:00:00.000Z"} - ) + contract = ClubSettingsContract() modified_fields = [] request_kwargs = {} diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 0e5ec59d..cf6a54f4 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -78,7 +78,9 @@ class DeepLinks(CamelCaseModel): class ClubSettingsContract(CamelCaseModel): description: Optional[str] - creation_date_utc: datetime + creation_date_utc: datetime = datetime.strptime( + "0001-01-01T00:00:00.000Z", "%Y-%m-%dT%H:%M:%S.%fZ" + ) background_image_url: Optional[str] display_image_url: Optional[str] preferred_color: Optional[PreferredColor] From f6e20b203daba790e30c9e049f4ee701f79d7caa Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 02:12:47 -0400 Subject: [PATCH 14/44] support /clubs/recommendationsByTitle --- xbox/webapi/api/provider/clubs/__init__.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 2f4998f8..9410f5ac 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -309,13 +309,20 @@ async def get_club_associations( resp.raise_for_status() return [club for club in SearchClubsResponse.parse_raw(resp.text).clubs] - async def get_club_recommendations(self, **kwargs) -> List[Club]: + async def get_club_recommendations( + self, title_id: Optional[str] = None, **kwargs + ) -> List[Club]: """Get clubs recommendations for the caller.""" - url = self.CLUBHUB_URL + f"/clubs/recommendations" - resp = await self.client.session.post( - url, headers=self.HEADERS_CLUBHUB, **kwargs - ) + method = self.client.session.post + endpoint = "/clubs/recommendations" + if title_id: + method = self.client.session.get + endpoint += f"ByTitle({title_id})" + endpoint += "/decoration/detail" + + url = self.CLUBHUB_URL + endpoint + resp = await method(url, headers=self.HEADERS_CLUBHUB, **kwargs) resp.raise_for_status() return [club for club in SearchClubsResponse.parse_raw(resp.text).clubs] From 416907dc81efd98cda1f0341e1fee35911339817 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 02:15:21 -0400 Subject: [PATCH 15/44] Initial FeedProvider implementation --- xbox/webapi/api/client.py | 2 + xbox/webapi/api/provider/clubs/__init__.py | 24 +- xbox/webapi/api/provider/feed/__init__.py | 191 ++++++++++++++++ xbox/webapi/api/provider/feed/models.py | 252 +++++++++++++++++++++ 4 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 xbox/webapi/api/provider/feed/__init__.py create mode 100644 xbox/webapi/api/provider/feed/models.py diff --git a/xbox/webapi/api/client.py b/xbox/webapi/api/client.py index 0bef80da..68ef6d51 100644 --- a/xbox/webapi/api/client.py +++ b/xbox/webapi/api/client.py @@ -16,6 +16,7 @@ from xbox.webapi.api.provider.catalog import CatalogProvider from xbox.webapi.api.provider.clubs import ClubProvider from xbox.webapi.api.provider.cqs import CQSProvider +from xbox.webapi.api.provider.feed import FeedProvider from xbox.webapi.api.provider.gameclips import GameclipProvider from xbox.webapi.api.provider.lists import ListsProvider from xbox.webapi.api.provider.mediahub import MediahubProvider @@ -132,6 +133,7 @@ def __init__( self.catalog = CatalogProvider(self) self.smartglass = SmartglassProvider(self) self.clubs = ClubProvider(self) + self.feed = FeedProvider(self) @property def xuid(self) -> str: diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 9410f5ac..3030be46 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -2,8 +2,6 @@ Clubs Manage clubs and club information. - -TODO: Club messaging/activity feed """ from collections.abc import Sequence import json @@ -39,16 +37,17 @@ class ClubProvider(BaseProvider): CLUBPRESENCE_URL = "https://clubpresence.xboxlive.com" CLUBPROFILE_URL = "https://clubprofile.xboxlive.com" CLUBROSTER_URL = "https://clubroster.xboxlive.com" - # CHATFD_URL = "https://chatfd.xboxlive.com" # CLUBSEARCH_URL = 'https://clubsearch.xboxlive.com' - HEADERS_CLUBACCOUNTS = {"x-xbl-contract-version": "1"} - HEADERS_OWNED_CLUBS = HEADERS_CLUBACCOUNTS | {"x-xbl-contract-version": "2"} - HEADERS_CLUBHUB = {"x-xbl-contract-version": "5", "Accept-Language": "en-US"} - HEADERS_CLUBPRESENCE = {"x-xbl-contract-version": "1"} - HEADERS_CLUBPROFILE = {"x-xbl-contract-version": "2"} - HEADERS_CLUBROSTER = {"x-xbl-contract-version": "4"} - # HEADERS_CHATFD = {"x-xbl-contract-version": "1"} + _HEADERS_COMMON = { + "Accept": "application/json", + "Accept-Language": "en-US, en, en-AU, en, en-GB, en, en-CA, en, en-AS, en, en-AT, en, en-BB, en", + } + HEADERS_CLUBACCOUNTS = _HEADERS_COMMON | {"x-xbl-contract-version": "1"} + HEADERS_CLUBHUB = _HEADERS_COMMON | {"x-xbl-contract-version": "5"} + HEADERS_CLUBPRESENCE = _HEADERS_COMMON | {"x-xbl-contract-version": "1"} + HEADERS_CLUBPROFILE = _HEADERS_COMMON | {"x-xbl-contract-version": "2"} + HEADERS_CLUBROSTER = _HEADERS_COMMON | {"x-xbl-contract-version": "4"} # HEADERS_CLUBSEARCH = {'x-xbl-contract-version': '2'} SEPARATOR = "," @@ -79,12 +78,11 @@ async def get_club_summary( async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: """Get list of clubs owned by the caller.""" + headers = self.HEADERS_CLUBACCOUNTS | {"x-xbl-contract-version": "2"} url = self.CLUBACCOUNTS_URL + f"/users/xuid({self.client.xuid})/clubsowned" - resp = await self.client.session.get( - url, headers=self.HEADERS_OWNED_CLUBS, **kwargs - ) + resp = await self.client.session.get(url, headers=headers, **kwargs) resp.raise_for_status() return OwnedClubsResponse.parse_raw(resp.text) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py new file mode 100644 index 00000000..bc19fd01 --- /dev/null +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -0,0 +1,191 @@ +""" +Feed + +Manage activity & chat feeds. +""" +from calendar import monthrange +from datetime import datetime +import json +import math +from typing import List, Optional +from urllib.parse import quote + +from xbox.webapi.api.provider.baseprovider import BaseProvider +from xbox.webapi.api.provider.feed.models import ( + ActivityItemType, + ContentType, + FeedResponse, + Message, + MessageResponse, + MessagesResponse, + ReportedItem, + ReportedItemsResponse, +) + + +class FeedProvider(BaseProvider): + ACTIVITY_URL = "https://avty.xboxlive.com" + CHATFEED_URL = "https://chatfd.xboxlive.com" + CLUBMODERATION_URL = "https://clubmoderation.xboxlive.com" + + HEADERS_ACTIVITY = {"x-xbl-contract-version": "12"} + HEADERS_CHATFEED = {"x-xbl-contract-version": "1"} + HEADERS_CLUBMODERATION = {"x-xbl-contract-version": "1"} + + # ACTIVITY + # --------------------------------------------------------------------------- + + @staticmethod + def _feed_start_date_time(months_ago: int, end_of_month: bool) -> datetime: + years_ago = math.floor(months_ago / 12) + months_ago = months_ago % 12 + + now = datetime.utcnow() + year = now.year if now.month > months_ago else now.year - (years_ago + 1) + month = now.month - months_ago if now.month > months_ago else 12 + return now.replace( + year=year, month=month, day=monthrange(year, month)[int(end_of_month)] + ) + + async def get_club_activity_feed( + self, + club_id: str, + max_items: int = 50, + exclude_types: Optional[List[ActivityItemType]] = None, + **kwargs, + ) -> None: + params = { + "excludeTypes": ";".join(exclude_types), + # "startDateTime": start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), + } + + url = self.ACTIVITY_URL + f"/clubs/clubId({club_id})/activity/feed" + resp = await self.client.session.get( + url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs + ) + resp.raise_for_status() + + async def get_title_activity_feed( + self, + title_id: str, + max_items: int = 50, + exclude_types: Optional[List[ActivityItemType]] = None, + start_date_time: Optional[datetime] = None, + **kwargs, + ) -> FeedResponse: + if exclude_types is None: + exclude_types = [ + ActivityItemType.FOLLOWED, + ActivityItemType.GAMERTAG_CHANGED, + ActivityItemType.PLAYED, + ] + + # Default start date is 2-3 months ago + if start_date_time is None: + start_date_time = self._feed_start_date_time( + months_ago=3, end_of_month=True + ) + + params = { + "excludeTypes": ";".join(exclude_types), + "startDateTime": quote( + start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), safe="" + ), + } + + url = self.ACTIVITY_URL + f"/titles/titleId({title_id})/activity/feed" + resp = await self.client.session.get( + url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs + ) + resp.raise_for_status() + + print(resp.text) + + return FeedResponse.parse_raw(resp.text) + + # CHAT FEED + # --------------------------------------------------------------------------- + + async def get_club_message_history( + self, club_id: str, message_id: str, max_items: int = 100, **kwargs + ) -> List[Message]: + params = {"messageId": message_id, "maxItems": str(max_items)} + + url = self.CHATFEED_URL + f"/channel/Club/{club_id}/messages/history" + + resp = await self.client.session.get( + url, headers=self.HEADERS_CHATFEED, params=params, **kwargs + ) + resp.raise_for_status() + + return MessagesResponse.parse_raw(resp.text).messages + + async def delete_club_message( + self, club_id: str, message_id: str, **kwargs + ) -> None: + url = self.CHATFEED_URL + f"/channel/Club/{club_id}/messages/{message_id}" + + resp = await self.client.session.delete( + url, headers=self.HEADERS_CHATFEED, **kwargs + ) + resp.raise_for_status() + + async def get_club_motd(self, club_id: str, **kwargs) -> Message: + url = self.CHATFEED_URL + f"/channel/Club/{club_id}/motd" + + resp = await self.client.session.get( + url, headers=self.HEADERS_CHATFEED, **kwargs + ) + resp.raise_for_status() + + return MessageResponse.parse_raw(resp.text).message + + async def set_club_motd(self, club_id: str, motd: str, **kwargs) -> None: + data = {"newMotd": motd} + + url = self.CHATFEED_URL + f"/channel/Club/{club_id}/motd" + + resp = await self.client.session.put( + url, headers=self.HEADERS_CHATFEED, json=data, **kwargs + ) + resp.raise_for_status() + + # CLUB MODERATION + # --------------------------------------------------------------------------- + + async def get_club_reported_items( + self, club_id: str, **kwargs + ) -> List[ReportedItem]: + url = self.CLUBMODERATION_URL + f"/clubs/{club_id}/reportedItems" + + resp = await self.client.session.get( + url, headers=self.HEADERS_CLUBMODERATION, **kwargs + ) + resp.raise_for_status() + + return ReportedItemsResponse.parse_raw(resp.text).reportedItems + + async def send_club_report( + self, + club_id: str, + content_id: str, + content_type: ContentType, + target_xuid: str, + reason: str, + **kwargs, + ) -> str: + data = { + "contentId": content_id, + "contentType": content_type, + "targetXuid": target_xuid, + "textReason": reason, + } + + url = self.CLUBMODERATION_URL + f"/clubs/{club_id}/reportedItems" + + resp = await self.client.session.post( + url, headers=self.HEADERS_CLUBMODERATION, json=data, **kwargs + ) + resp.raise_for_status() + + return json.loads(resp.text)["reportId"] diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py new file mode 100644 index 00000000..f22c6de5 --- /dev/null +++ b/xbox/webapi/api/provider/feed/models.py @@ -0,0 +1,252 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Generic, List, Optional, TypeVar, Union +from uuid import UUID + +from xbox.webapi.api.provider.clubs import ClubRole +from xbox.webapi.common.models import CamelCaseModel + + +class AchievementType(str, Enum): + UNKNOWN = "Unknown" + PERSISTENT = "Persistent" + + +class ActivityItemType(str, Enum): + UNKNOWN = "Unknown" + PLAYED = "Played" + FOLLOWED = "Followed" + TEXT_POST = "TextPost" + USER_POST = "UserPost" + ACHIEVEMENT = "Achievement" + SCREENSHOT = "Screenshot" + CLIP = "GameDVR" + BROADCAST_START = "BroadcastStart" + BROADCAST_END = "BroadcastEnd" + GAMERTAG_CHANGED = "GamertagChanged" + + +class AuthorType(str, Enum): + UNKNOWN = "Unknown" + USER = "User" + + +class ContentType(str, Enum): + UNKNOWN = "Unknown" + SYSTEM = "System" + CHAT = "Chat" + GAME = "Game" + + +class LocatorType(str, Enum): + UNKNOWN = "Unknown" + DOWNLOAD = "Download" + THUMBNAIL_SMALL = "Thumbnail_Small" + THUMBNAIL_LARGE = "Thumbnail_Large" + + +class MessageStatus(str, Enum): + UNKNOWN = "Unknown" + OK = "Ok" + DELETED = "Deleted" # flag 1 + + +class MessageType(str, Enum): + UNKNOWN = "Unknown" + BASIC_TEXT = "BasicText" # flag 0 + RICH_TEXT = "RichText" # flag 16 + DIRECT_MENTION = "DirectMention" # flag 20 + MOTD = "MessageOfTheDay" # flag 0 + + +class PostType(str, Enum): + UNKNOWN = "Unknown" + LINK = "Link" + + +class LinkType(str, Enum): + UNKNOWN = "Unknown" + DEFAULT = "Default" + + +class TimelineType(str, Enum): + UNKNOWN = "Unknown" + CLUB = "Club" + + +class RarityCategory(str, Enum): + UNKNOWN = "Unknown" + RARE = "Rare" + + +class Report(CamelCaseModel): + reporting_xuid: str + text_reason: str + + +class ReportedItem(CamelCaseModel): + content_id: str + content_type: ContentType + last_reported: datetime + report_count: int + report_id: str + reports: List[Report] + + +class UserPostDetails(CamelCaseModel): + link: str + display_link: str + title: str + description: str + link_type: LinkType + link_data: Optional[Any] + + +class PreferredColor(CamelCaseModel): + primary_color: Optional[str] + secondary_color: Optional[str] + tertiary_color: Optional[str] + + +class AuthorInfo(CamelCaseModel): + author_type: AuthorType + color: PreferredColor + image_url: str + modern_gamertag: str + modern_gamertag_suffix: str + name: str + second_name: str + show_as_avatar: str + + +class Message(CamelCaseModel): + protocol_version: int + message_id: str + message_time: datetime + message_type: MessageType + sender_xuid: str + sender_gamertag: str + client_seq_num: int + message: str + message_status: MessageStatus + flags: int + + +class Timeline(CamelCaseModel): + timeline_id: str + timeline_type: TimelineType + timeline_name: str + timeline_image: str + + +class ClubTimeline(Timeline): + is_official_club: bool + author_roles: List[ClubRole] + is_public: bool + + +class GameMediaContentLocator(CamelCaseModel): + expiration: Optional[datetime] + file_size: Optional[int] + locator_type: Optional[LocatorType] + uri: Optional[str] + + +class ActivityItem(CamelCaseModel): + bing_id: Optional[UUID] + content_image_uri: Optional[str] + content_title: Optional[str] + game_media_content_locators: Optional[List[GameMediaContentLocator]] + platform: Optional[str] # Platform item was posted from + title_id: Optional[str] + upload_title_id: Optional[str] + description: str + date: datetime + has_ugc: bool + activity_item_type: ActivityItemType + content_type: ContentType + short_description: str + ugc_caption: Optional[str] + item_text: str + item_image: str + trusted_item_image: Optional[bool] + share_root: str + feed_item_id: str + item_root: str + view_count: Optional[int] + num_likes: Optional[int] + num_comments: Optional[int] + num_shares: Optional[int] + num_views: Optional[int] + has_liked: bool + author_info: AuthorInfo + user_xuid: str + + +class AchievementActivityItem(ActivityItem): + achievement_description: str + achievement_icon: str + achievement_id: str + achievement_name: str + achievement_scid: UUID + achievement_type: str + gamerscore: int + has_app_award: bool + has_art_award: bool + is_secret: bool + rarity_category: str + rarity_percentage: str + + +class ClipActivityItem(ActivityItem): + clip_caption: str + clip_id: UUID + clip_name: str + clip_scid: UUID + clip_thumbnail: str + date_recorded: datetime + duration_in_seconds: str + + +class ScreenshotActivityItem(ActivityItem): + screenshot_id: UUID + screenshot_name: str + screenshot_scid: UUID + screenshot_thumbnail: str + screenshot_uri: str + + +class UserPostActivityItem(ActivityItem): + post_type: PostType + post_details: UserPostDetails + timeline: ClubTimeline + + +class FeedResponse(CamelCaseModel): + num_items: int + activity_items: List[ + Union[ + AchievementActivityItem, + ScreenshotActivityItem, + ClipActivityItem, + UserPostActivityItem, + ] + ] + cont_token: str + polling_interval_seconds: Optional[str] + polling_token: str + + class Config: + smart_union = True + + +class MessageResponse(CamelCaseModel): + message: Message + + +class MessagesResponse(CamelCaseModel): + messages: List[Message] + + +class ReportedItemsResponse(CamelCaseModel): + reportedItems: List[ReportedItem] From e5ce8f2793390d77633a3ffcb912860c679d2b33 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 02:15:50 -0400 Subject: [PATCH 16/44] Rename FeedResponse to ActivityResponse --- xbox/webapi/api/provider/clubs/__init__.py | 4 ++-- xbox/webapi/api/provider/feed/__init__.py | 6 +++--- xbox/webapi/api/provider/feed/models.py | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 3030be46..40736df8 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -195,7 +195,7 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: async def delete_club( self, club_id: str, actor: Optional[str] = None, **kwargs - ) -> bool: + ) -> ClubReservation: """Delete the club with the given id. Codes @@ -211,7 +211,7 @@ async def delete_club( ) resp.raise_for_status() - return resp.status == 204 + return ClubReservation.parse_raw(resp.text) # CLUB HUB # --------------------------------------------------------------------------- diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index bc19fd01..edb2e33f 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -13,8 +13,8 @@ from xbox.webapi.api.provider.baseprovider import BaseProvider from xbox.webapi.api.provider.feed.models import ( ActivityItemType, + ActivityResponse, ContentType, - FeedResponse, Message, MessageResponse, MessagesResponse, @@ -72,7 +72,7 @@ async def get_title_activity_feed( exclude_types: Optional[List[ActivityItemType]] = None, start_date_time: Optional[datetime] = None, **kwargs, - ) -> FeedResponse: + ) -> ActivityResponse: if exclude_types is None: exclude_types = [ ActivityItemType.FOLLOWED, @@ -101,7 +101,7 @@ async def get_title_activity_feed( print(resp.text) - return FeedResponse.parse_raw(resp.text) + return ActivityResponse.parse_raw(resp.text) # CHAT FEED # --------------------------------------------------------------------------- diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index f22c6de5..8df43838 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -19,8 +19,9 @@ class ActivityItemType(str, Enum): TEXT_POST = "TextPost" USER_POST = "UserPost" ACHIEVEMENT = "Achievement" + ACHIEVEMENT_LEGACY = "LegacyAchievement" SCREENSHOT = "Screenshot" - CLIP = "GameDVR" + GAME_DVR = "GameDVR" BROADCAST_START = "BroadcastStart" BROADCAST_END = "BroadcastEnd" GAMERTAG_CHANGED = "GamertagChanged" @@ -222,7 +223,7 @@ class UserPostActivityItem(ActivityItem): timeline: ClubTimeline -class FeedResponse(CamelCaseModel): +class ActivityResponse(CamelCaseModel): num_items: int activity_items: List[ Union[ From 989367455f1b089acc49696bbaab24e9928d3b10 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 02:52:48 -0400 Subject: [PATCH 17/44] get_user_activity_history() and get_club_activity_feed() --- xbox/webapi/api/provider/feed/__init__.py | 47 +++++++++++++++++++---- xbox/webapi/api/provider/feed/models.py | 13 ++++--- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index edb2e33f..5f65761b 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -47,17 +47,49 @@ def _feed_start_date_time(months_ago: int, end_of_month: bool) -> datetime: year=year, month=month, day=monthrange(year, month)[int(end_of_month)] ) + async def get_user_activity_history( + self, + xuid: Optional[str], + num_items: int = 20, + activity_types: Optional[List[ActivityItemType]] = None, + **kwargs, + ) -> ActivityResponse: + if activity_types is None: + activity_types = [ + ActivityItemType.GAME_DVR, + ActivityItemType.ACHIEVEMENT_LEGACY, + ActivityItemType.SCREENSHOT, + ] + + params = {"numItems": str(num_items), "activityTypes": ";".join(activity_types)} + + url = self.ACTIVITY_URL + f"/users/xuid" + if xuid is None: + url += f"({self.client.xuid})/Activity/History/UnShared" + else: + url += f"({xuid})/Activity/History" + + resp = await self.client.session.get( + url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs + ) + resp.raise_for_status() + + return ActivityResponse.parse_raw(resp.text) + async def get_club_activity_feed( self, club_id: str, - max_items: int = 50, + num_items: int = 50, exclude_types: Optional[List[ActivityItemType]] = None, **kwargs, - ) -> None: - params = { - "excludeTypes": ";".join(exclude_types), - # "startDateTime": start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), - } + ) -> ActivityResponse: + if exclude_types is None: + exclude_types = [ + ActivityItemType.BROADCAST_START, + ActivityItemType.BROADCAST_END, + ] + + params = {"numItems": str(num_items), "excludeTypes": ";".join(exclude_types)} url = self.ACTIVITY_URL + f"/clubs/clubId({club_id})/activity/feed" resp = await self.client.session.get( @@ -65,10 +97,11 @@ async def get_club_activity_feed( ) resp.raise_for_status() + return ActivityResponse.parse_raw(resp.text) + async def get_title_activity_feed( self, title_id: str, - max_items: int = 50, exclude_types: Optional[List[ActivityItemType]] = None, start_date_time: Optional[datetime] = None, **kwargs, diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index 8df43838..f788ac52 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Generic, List, Optional, TypeVar, Union +from typing import Any, List, Optional, Union from uuid import UUID from xbox.webapi.api.provider.clubs import ClubRole @@ -110,14 +110,15 @@ class PreferredColor(CamelCaseModel): class AuthorInfo(CamelCaseModel): - author_type: AuthorType - color: PreferredColor - image_url: str modern_gamertag: str modern_gamertag_suffix: str name: str second_name: str + image_url: str + color: PreferredColor show_as_avatar: str + author_type: AuthorType + id: str class Message(CamelCaseModel): @@ -169,7 +170,7 @@ class ActivityItem(CamelCaseModel): short_description: str ugc_caption: Optional[str] item_text: str - item_image: str + item_image: Optional[str] trusted_item_image: Optional[bool] share_root: str feed_item_id: str @@ -182,6 +183,7 @@ class ActivityItem(CamelCaseModel): has_liked: bool author_info: AuthorInfo user_xuid: str + pinned: Optional[bool] class AchievementActivityItem(ActivityItem): @@ -231,6 +233,7 @@ class ActivityResponse(CamelCaseModel): ScreenshotActivityItem, ClipActivityItem, UserPostActivityItem, + ActivityItem, ] ] cont_token: str From f229e09f4679ad8e106a95d97f351ce421765f6d Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 03:12:20 -0400 Subject: [PATCH 18/44] Platform Enum --- xbox/webapi/api/provider/feed/models.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index f788ac52..c79884c4 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -80,6 +80,17 @@ class RarityCategory(str, Enum): RARE = "Rare" +class Platform(str, Enum): + UNKNOWN = "Unknown" + XBOX_360 = "Xenon" + XBOX_ONE = "Durango" + XBOX_ONE_S = "Edmonton" + XBOX_ONE_X = "Scorpio" + XBOX_SERIES_S = "Lockhart" + XBOX_SERIES_X = "Scarlett" + WINDOWS = "Win32" + + class Report(CamelCaseModel): reporting_xuid: str text_reason: str @@ -159,7 +170,7 @@ class ActivityItem(CamelCaseModel): content_image_uri: Optional[str] content_title: Optional[str] game_media_content_locators: Optional[List[GameMediaContentLocator]] - platform: Optional[str] # Platform item was posted from + platform: Optional[Platform] # System item was posted from title_id: Optional[str] upload_title_id: Optional[str] description: str From 775a6e784721847b9ed01e82dc04c10dbfec2015 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 04:22:55 -0400 Subject: [PATCH 19/44] Make activity endpoint methods cleaner --- xbox/webapi/api/provider/feed/__init__.py | 114 ++++++++++++---------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 5f65761b..004be53e 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -47,94 +47,108 @@ def _feed_start_date_time(months_ago: int, end_of_month: bool) -> datetime: year=year, month=month, day=monthrange(year, month)[int(end_of_month)] ) + async def _send_activity_request( + self, url: str, **activity_params + ) -> ActivityResponse: + params = {} + + num_items = activity_params.pop("num_items") + if num_items is not None: + params["numItems"] = str(num_items) + + activity_types = activity_params.pop("activity_types") + if activity_types: + params["activityTypes"] = ";".join(activity_types) + + exclude_types = activity_params.pop("exclude_types") + if exclude_types: + params["excludeTypes"] = ";".join(exclude_types) + + start_date_time = activity_params.pop("start_date_time") + if start_date_time: + params["startDateTime"] = quote( + start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), safe="" + ) + + # All leftover "params" are assumed to be request kwargs + request_kwargs = activity_params + + resp = await self.client.session.get( + url, headers=self.HEADERS_ACTIVITY, params=params, **request_kwargs + ) + resp.raise_for_status() + + return ActivityResponse.parse_raw(resp.text) + async def get_user_activity_history( self, xuid: Optional[str], - num_items: int = 20, - activity_types: Optional[List[ActivityItemType]] = None, - **kwargs, + **activity_params, ) -> ActivityResponse: - if activity_types is None: - activity_types = [ + if activity_params.get("num_items") is None: + activity_params["num_items"] = 20 + + if ( + activity_params.get("activity_types") is None + and activity_params.get("exclude_types") is None + ): + activity_params["activity_types"] = [ ActivityItemType.GAME_DVR, ActivityItemType.ACHIEVEMENT_LEGACY, ActivityItemType.SCREENSHOT, ] - params = {"numItems": str(num_items), "activityTypes": ";".join(activity_types)} - - url = self.ACTIVITY_URL + f"/users/xuid" + url = f"{self.ACTIVITY_URL}/users/xuid({xuid or self.client.xuid})/Activity/History" if xuid is None: - url += f"({self.client.xuid})/Activity/History/UnShared" - else: - url += f"({xuid})/Activity/History" + url += "/UnShared" - resp = await self.client.session.get( - url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs - ) - resp.raise_for_status() - - return ActivityResponse.parse_raw(resp.text) + return await self._send_activity_request(url, **activity_params) async def get_club_activity_feed( self, club_id: str, - num_items: int = 50, - exclude_types: Optional[List[ActivityItemType]] = None, - **kwargs, + **activity_params, ) -> ActivityResponse: - if exclude_types is None: - exclude_types = [ + if activity_params.get("num_items") is None: + activity_params["num_items"] = 50 + + if ( + activity_params.get("activity_types") is None + and activity_params.get("exclude_types") is None + ): + activity_params["exclude_types"] = [ ActivityItemType.BROADCAST_START, ActivityItemType.BROADCAST_END, ] - params = {"numItems": str(num_items), "excludeTypes": ";".join(exclude_types)} - url = self.ACTIVITY_URL + f"/clubs/clubId({club_id})/activity/feed" - resp = await self.client.session.get( - url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs - ) - resp.raise_for_status() - return ActivityResponse.parse_raw(resp.text) + return await self._send_activity_request(url, **activity_params) async def get_title_activity_feed( self, title_id: str, - exclude_types: Optional[List[ActivityItemType]] = None, - start_date_time: Optional[datetime] = None, - **kwargs, + **activity_params, ) -> ActivityResponse: - if exclude_types is None: - exclude_types = [ + if ( + activity_params.get("activity_types") is None + and activity_params.get("exclude_types") is None + ): + activity_params["exclude_types"] = [ ActivityItemType.FOLLOWED, ActivityItemType.GAMERTAG_CHANGED, ActivityItemType.PLAYED, ] # Default start date is 2-3 months ago - if start_date_time is None: - start_date_time = self._feed_start_date_time( + if activity_params.get("start_date_time") is None: + activity_params["start_date_time"] = self._feed_start_date_time( months_ago=3, end_of_month=True ) - params = { - "excludeTypes": ";".join(exclude_types), - "startDateTime": quote( - start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), safe="" - ), - } - url = self.ACTIVITY_URL + f"/titles/titleId({title_id})/activity/feed" - resp = await self.client.session.get( - url, headers=self.HEADERS_ACTIVITY, params=params, **kwargs - ) - resp.raise_for_status() - print(resp.text) - - return ActivityResponse.parse_raw(resp.text) + return await self._send_activity_request(url, **activity_params) # CHAT FEED # --------------------------------------------------------------------------- From a59d1ce20ddb2526b5b00548fd1905546c28abaa Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 04:44:04 -0400 Subject: [PATCH 20/44] Add more delete_club() docs --- xbox/webapi/api/provider/clubs/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 40736df8..5e005a20 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -195,12 +195,18 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: async def delete_club( self, club_id: str, actor: Optional[str] = None, **kwargs - ) -> ClubReservation: + ) -> Optional[ClubReservation]: """Delete the club with the given id. + If a club is not hidden and is older than one week you will receive a ClubReservation for the club name, + and it will be suspended for 7 days before being automatically deleted. + + The ClubReservation should last for an hour after the club is deleted. + Codes - 204: Successfully deleted club. - 409: Another pending operation in progress. + - 1021: The actor specified for the suspension record is not valid. """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" if actor: @@ -211,7 +217,8 @@ async def delete_club( ) resp.raise_for_status() - return ClubReservation.parse_raw(resp.text) + if resp.text: + return ClubReservation.parse_raw(resp.text) # CLUB HUB # --------------------------------------------------------------------------- From 20dc59176a6dbba6d0f70b421418a68b0eb8f63d Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 04:44:04 -0400 Subject: [PATCH 21/44] get_xbox_activity_feed() and get_user_pins() --- xbox/webapi/api/provider/feed/__init__.py | 50 +++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 004be53e..2f87daff 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -52,19 +52,23 @@ async def _send_activity_request( ) -> ActivityResponse: params = {} - num_items = activity_params.pop("num_items") + num_items = activity_params.pop("num_items", None) if num_items is not None: params["numItems"] = str(num_items) - activity_types = activity_params.pop("activity_types") + include_self = activity_params.pop("include_self", None) + if include_self is not None: + params["includeSelf"] = str(num_items).lower() + + activity_types = activity_params.pop("activity_types", None) if activity_types: params["activityTypes"] = ";".join(activity_types) - exclude_types = activity_params.pop("exclude_types") + exclude_types = activity_params.pop("exclude_types", None) if exclude_types: params["excludeTypes"] = ";".join(exclude_types) - start_date_time = activity_params.pop("start_date_time") + start_date_time = activity_params.pop("start_date_time", None) if start_date_time: params["startDateTime"] = quote( start_date_time.strftime("%m/%d/%Y+%H:%M:%S"), safe="" @@ -150,6 +154,44 @@ async def get_title_activity_feed( return await self._send_activity_request(url, **activity_params) + async def get_xbox_activity_feed( + self, + **activity_params, + ) -> ActivityResponse: + if activity_params.get("num_items") is None: + activity_params["num_items"] = 50 + + if activity_params.get("include_self") is None: + activity_params["include_self"] = True + + if ( + activity_params.get("activity_types") is None + and activity_params.get("exclude_types") is None + ): + activity_params["exclude_types"] = [ + ActivityItemType.PLAYED, + ActivityItemType.BROADCAST_START, + ActivityItemType.BROADCAST_END, + ] + + url = self.ACTIVITY_URL + f"/users/xuid({self.client.xuid})/XboxFeed" + + return await self._send_activity_request(url, **activity_params) + + async def get_user_pins( + self, xuid: Optional[str] = None, **kwargs + ) -> ActivityResponse: + xuid = xuid or self.client.xuid + + url = self.ACTIVITY_URL + f"/timelines/User/{xuid}/pins" + + resp = await self.client.session.get( + url, headers=self.HEADERS_ACTIVITY, **kwargs + ) + resp.raise_for_status() + + return ActivityResponse.parse_raw(resp.text) + # CHAT FEED # --------------------------------------------------------------------------- From 82551521c4a81c21ace84a4ce948f96631ec1666 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 07:57:57 -0400 Subject: [PATCH 22/44] update feed models --- xbox/webapi/api/provider/feed/models.py | 101 ++++++++++++++++++------ 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index c79884c4..f9b898c7 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -3,7 +3,7 @@ from typing import Any, List, Optional, Union from uuid import UUID -from xbox.webapi.api.provider.clubs import ClubRole +from xbox.webapi.api.provider.clubs.models import ClubRole from xbox.webapi.common.models import CamelCaseModel @@ -25,11 +25,13 @@ class ActivityItemType(str, Enum): BROADCAST_START = "BroadcastStart" BROADCAST_END = "BroadcastEnd" GAMERTAG_CHANGED = "GamertagChanged" + CONTAINER = "Container" class AuthorType(str, Enum): UNKNOWN = "Unknown" USER = "User" + TITLE = "TitleUser" class ContentType(str, Enum): @@ -63,6 +65,7 @@ class MessageType(str, Enum): class PostType(str, Enum): UNKNOWN = "Unknown" LINK = "Link" + LINK_XBOX = "XboxLink" class LinkType(str, Enum): @@ -72,23 +75,37 @@ class LinkType(str, Enum): class TimelineType(str, Enum): UNKNOWN = "Unknown" + USER = "User" CLUB = "Club" class RarityCategory(str, Enum): UNKNOWN = "Unknown" + COMMON = "Common" RARE = "Rare" +class ItemSource(str, Enum): + UNKNOWN = "Unknown" + TRENDING = "Trending" + + class Platform(str, Enum): UNKNOWN = "Unknown" XBOX_360 = "Xenon" - XBOX_ONE = "Durango" + XBOX_ONE_OG = "Durango" + XBOX_ONE = "XboxOne" XBOX_ONE_S = "Edmonton" XBOX_ONE_X = "Scorpio" XBOX_SERIES_S = "Lockhart" XBOX_SERIES_X = "Scarlett" WINDOWS = "Win32" + WINDOWS_ONE_CORE = "WindowsOneCore" + + +class Title(CamelCaseModel): + title_id: int + title_name: str class Report(CamelCaseModel): @@ -120,9 +137,7 @@ class PreferredColor(CamelCaseModel): tertiary_color: Optional[str] -class AuthorInfo(CamelCaseModel): - modern_gamertag: str - modern_gamertag_suffix: str +class BaseAuthorInfo(CamelCaseModel): name: str second_name: str image_url: str @@ -132,6 +147,18 @@ class AuthorInfo(CamelCaseModel): id: str +class UserAuthorInfo(BaseAuthorInfo): + modern_gamertag: str + modern_gamertag_suffix: str + + +class TitleAuthorInfo(BaseAuthorInfo): + title_id: str + title_name: str + title_image: str + unfollowable_titles: List[Title] + + class Message(CamelCaseModel): protocol_version: int message_id: str @@ -153,11 +180,18 @@ class Timeline(CamelCaseModel): class ClubTimeline(Timeline): - is_official_club: bool + is_official_club: bool # Only for official clubs + unfollowable_titles: Optional[List[Title]] # Only for official clubs author_roles: List[ClubRole] is_public: bool +class UserTimeline(Timeline): + timeline_owner: str + date: datetime + timeline_uri: str + + class GameMediaContentLocator(CamelCaseModel): expiration: Optional[datetime] file_size: Optional[int] @@ -165,7 +199,16 @@ class GameMediaContentLocator(CamelCaseModel): uri: Optional[str] -class ActivityItem(CamelCaseModel): +class BaseActivityItem(CamelCaseModel): + description: str + has_ugc: bool + activity_item_type: ActivityItemType + short_description: str + item_text: str + has_liked: bool + + +class ActivityItem(BaseActivityItem): bing_id: Optional[UUID] content_image_uri: Optional[str] content_title: Optional[str] @@ -173,14 +216,9 @@ class ActivityItem(CamelCaseModel): platform: Optional[Platform] # System item was posted from title_id: Optional[str] upload_title_id: Optional[str] - description: str date: datetime - has_ugc: bool - activity_item_type: ActivityItemType content_type: ContentType - short_description: str ugc_caption: Optional[str] - item_text: str item_image: Optional[str] trusted_item_image: Optional[bool] share_root: str @@ -191,25 +229,27 @@ class ActivityItem(CamelCaseModel): num_comments: Optional[int] num_shares: Optional[int] num_views: Optional[int] - has_liked: bool - author_info: AuthorInfo + author_info: Union[UserAuthorInfo, TitleAuthorInfo] user_xuid: str pinned: Optional[bool] + class Config: + smart_union = True + class AchievementActivityItem(ActivityItem): - achievement_description: str - achievement_icon: str - achievement_id: str - achievement_name: str achievement_scid: UUID + achievement_id: str achievement_type: str + achievement_icon: str + achievement_name: str + rarity_category: str + rarity_percentage: int gamerscore: int + achievement_description: str + is_secret: bool has_app_award: bool has_art_award: bool - is_secret: bool - rarity_category: str - rarity_percentage: str class ClipActivityItem(ActivityItem): @@ -236,6 +276,19 @@ class UserPostActivityItem(ActivityItem): timeline: ClubTimeline +class ContainerActivityItem(BaseActivityItem): + item_source: ItemSource + feed_items: List[ + Union[ + AchievementActivityItem, + ScreenshotActivityItem, + ClipActivityItem, + UserPostActivityItem, + ActivityItem, + ] + ] + + class ActivityResponse(CamelCaseModel): num_items: int activity_items: List[ @@ -244,12 +297,14 @@ class ActivityResponse(CamelCaseModel): ScreenshotActivityItem, ClipActivityItem, UserPostActivityItem, + ContainerActivityItem, ActivityItem, ] ] - cont_token: str + cont_token: Optional[str] + max_pins: Optional[int] polling_interval_seconds: Optional[str] - polling_token: str + polling_token: Optional[str] class Config: smart_union = True From 3d2eaa34a0707952fbc7008084ef06cdda1a6208 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 08:31:25 -0400 Subject: [PATCH 23/44] add delete_feed_item() --- xbox/webapi/api/provider/feed/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 2f87daff..5cd9a34e 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -32,6 +32,14 @@ class FeedProvider(BaseProvider): HEADERS_CHATFEED = {"x-xbl-contract-version": "1"} HEADERS_CLUBMODERATION = {"x-xbl-contract-version": "1"} + async def delete_feed_item(self, feed_item_id: str, **kwargs) -> None: + headers = {"x-xbl-contract-version": "2"} + + url = f"https://{feed_item_id}" + + resp = await self.client.session.delete(url, headers=headers, **kwargs) + resp.raise_for_status() + # ACTIVITY # --------------------------------------------------------------------------- From 3795a7221d8a67c97cc843706ee39adb90eadff6 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 09:37:56 -0400 Subject: [PATCH 24/44] Initial comments.xboxlive.com and chatfd.xboxlive.com implementation --- xbox/webapi/api/provider/feed/__init__.py | 82 +++++++++++++++++++++++ xbox/webapi/api/provider/feed/models.py | 35 +++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 5cd9a34e..439034cf 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -18,17 +18,26 @@ Message, MessageResponse, MessagesResponse, + PathSummary, + PostResponse, + PostType, ReportedItem, ReportedItemsResponse, + SummariesResponse, + TimelineType, ) class FeedProvider(BaseProvider): ACTIVITY_URL = "https://avty.xboxlive.com" + USERPOSTS_URL = "https://userposts.xboxlive.com" + COMMENTS_URL = "https://comments.xboxlive.com" CHATFEED_URL = "https://chatfd.xboxlive.com" CLUBMODERATION_URL = "https://clubmoderation.xboxlive.com" HEADERS_ACTIVITY = {"x-xbl-contract-version": "12"} + HEADERS_USERPOSTS = {"x-xbl-contract-version": "2"} + HEADERS_COMMENTS = {"x-xbl-contract-version": "3"} HEADERS_CHATFEED = {"x-xbl-contract-version": "1"} HEADERS_CLUBMODERATION = {"x-xbl-contract-version": "1"} @@ -200,6 +209,79 @@ async def get_user_pins( return ActivityResponse.parse_raw(resp.text) + # USER POSTS + # --------------------------------------------------------------------------- + + async def post_text(self, text: str, **kwargs) -> PostResponse: + data = { + "postType": PostType.TEXT, + "postText": text, + "timelines": [ + { + "timelineType": TimelineType.USER, + "timelineOwner": self.client.xuid, + } + ], + } + + url = self.USERPOSTS_URL + f"/users/me/posts" + + resp = await self.client.session.post( + url, headers=self.HEADERS_USERPOSTS, json=data, **kwargs + ) + resp.raise_for_status() + + return PostResponse.parse_raw(resp.text) + + async def share_item( + self, + feed_item_id: str, + text: Optional[str] = None, + parent_id: Optional[str] = None, + **kwargs, + ) -> PostResponse: + post_type_data = {"locator": feed_item_id} + if parent_id: + post_type_data["parentId"] = parent_id + + data = { + "postType": PostType.LINK_XBOX, + "postText": text or "", + "postTypeData": post_type_data, + "timelines": [ + { + "timelineType": TimelineType.USER, + "timelineOwner": self.client.xuid, + } + ], + } + + url = self.USERPOSTS_URL + f"/users/me/posts" + + resp = await self.client.session.post( + url, headers=self.HEADERS_USERPOSTS, json=data, **kwargs + ) + resp.raise_for_status() + + return PostResponse.parse_raw(resp.text) + + # COMMENTS + # --------------------------------------------------------------------------- + + async def get_post_summaries( + self, post_paths: List[str], **kwargs + ) -> List[PathSummary]: + data = {"rootPaths": post_paths} + + url = self.COMMENTS_URL + f"/summaries/batch" + + resp = await self.client.session.post( + url, headers=self.COMMENTS_URL, json=data, **kwargs + ) + resp.raise_for_status() + + return SummariesResponse.parse_raw(resp.text).summaries + # CHAT FEED # --------------------------------------------------------------------------- diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index f9b898c7..2a5e5df2 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -64,6 +64,7 @@ class MessageType(str, Enum): class PostType(str, Enum): UNKNOWN = "Unknown" + TEXT = "Text" LINK = "Link" LINK_XBOX = "XboxLink" @@ -92,12 +93,12 @@ class ItemSource(str, Enum): class Platform(str, Enum): UNKNOWN = "Unknown" - XBOX_360 = "Xenon" + XBOX_360 = "Xenon" # Not observed XBOX_ONE_OG = "Durango" XBOX_ONE = "XboxOne" XBOX_ONE_S = "Edmonton" XBOX_ONE_X = "Scorpio" - XBOX_SERIES_S = "Lockhart" + XBOX_SERIES_S = "Lockhart" # Not observed XBOX_SERIES_X = "Scarlett" WINDOWS = "Win32" WINDOWS_ONE_CORE = "WindowsOneCore" @@ -172,6 +173,13 @@ class Message(CamelCaseModel): flags: int +class PostTimeline(CamelCaseModel): + timeline_type: TimelineType + timeline_owner: str + date: Optional[datetime] + timeline_uri: str + + class Timeline(CamelCaseModel): timeline_id: str timeline_type: TimelineType @@ -192,6 +200,14 @@ class UserTimeline(Timeline): timeline_uri: str +class PathSummary(CamelCaseModel): + type: str + path: str + like_count: int + comment_count: int + share_count: int + + class GameMediaContentLocator(CamelCaseModel): expiration: Optional[datetime] file_size: Optional[int] @@ -320,3 +336,18 @@ class MessagesResponse(CamelCaseModel): class ReportedItemsResponse(CamelCaseModel): reportedItems: List[ReportedItem] + + +class PostResponse(CamelCaseModel): + post_uri: str + post_type: PostType + post_author: str + post_id: UUID + post_text: str + timelines: List[PostTimeline] + post_date: datetime + post_content_locators: Optional[List[GameMediaContentLocator]] + + +class SummariesResponse(CamelCaseModel): + summaries: List[PathSummary] From c3449d28c386a68c0a30ffe61ffa340a79414ac5 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 09:38:15 -0400 Subject: [PATCH 25/44] pin_post() --- xbox/webapi/api/provider/feed/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 439034cf..f804b6fb 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -198,9 +198,7 @@ async def get_xbox_activity_feed( async def get_user_pins( self, xuid: Optional[str] = None, **kwargs ) -> ActivityResponse: - xuid = xuid or self.client.xuid - - url = self.ACTIVITY_URL + f"/timelines/User/{xuid}/pins" + url = self.ACTIVITY_URL + f"/timelines/User/{xuid or self.client.xuid}/pins" resp = await self.client.session.get( url, headers=self.HEADERS_ACTIVITY, **kwargs @@ -209,6 +207,16 @@ async def get_user_pins( return ActivityResponse.parse_raw(resp.text) + async def pin_post(self, feed_item_id: str, **kwargs) -> None: + data = {"locator": feed_item_id} + + url = self.ACTIVITY_URL + f"/timelines/User/{self.client.xuid}/pins" + + resp = await self.client.session.post( + url, headers=self.HEADERS_ACTIVITY, json=data, **kwargs + ) + resp.raise_for_status() + # USER POSTS # --------------------------------------------------------------------------- From 5e12fe7567d346b3da7cb227d48bdf5f4dbd8d27 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 09:58:40 -0400 Subject: [PATCH 26/44] unpin_post() --- xbox/webapi/api/provider/feed/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index f804b6fb..42f1aa6b 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -103,7 +103,7 @@ async def _send_activity_request( async def get_user_activity_history( self, - xuid: Optional[str], + xuid: Optional[str] = None, **activity_params, ) -> ActivityResponse: if activity_params.get("num_items") is None: @@ -217,6 +217,16 @@ async def pin_post(self, feed_item_id: str, **kwargs) -> None: ) resp.raise_for_status() + async def unpin_post(self, feed_item_id: str, **kwargs) -> None: + data = {"locator": feed_item_id} + + url = self.ACTIVITY_URL + f"/timelines/User/{self.client.xuid}/unpin" + + resp = await self.client.session.post( + url, headers=self.HEADERS_ACTIVITY, json=data, **kwargs + ) + resp.raise_for_status() + # USER POSTS # --------------------------------------------------------------------------- From 8f6b2b01a594afd81be2946515415e10d516472a Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 10:02:26 -0400 Subject: [PATCH 27/44] get_post_comments() --- xbox/webapi/api/provider/feed/__init__.py | 9 +++++++++ xbox/webapi/api/provider/feed/models.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 42f1aa6b..2ba39429 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -18,6 +18,7 @@ Message, MessageResponse, MessagesResponse, + PathCommentsResponse, PathSummary, PostResponse, PostType, @@ -300,6 +301,14 @@ async def get_post_summaries( return SummariesResponse.parse_raw(resp.text).summaries + async def get_post_comments(self, post_path: str, **kwargs) -> PathCommentsResponse: + url = self.COMMENTS_URL + f"/{post_path}" + + resp = await self.client.session.get(url, headers=self.COMMENTS_URL, **kwargs) + resp.raise_for_status() + + return PathCommentsResponse.parse_raw(resp.text) + # CHAT FEED # --------------------------------------------------------------------------- diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index 2a5e5df2..99ece0c6 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -208,6 +208,18 @@ class PathSummary(CamelCaseModel): share_count: int +class Comment(CamelCaseModel): + text: str + root_type: str + root_path: str + path: str + xuid: str + gamertag: str + date: datetime + id: str + parent_path: str + + class GameMediaContentLocator(CamelCaseModel): expiration: Optional[datetime] file_size: Optional[int] @@ -351,3 +363,13 @@ class PostResponse(CamelCaseModel): class SummariesResponse(CamelCaseModel): summaries: List[PathSummary] + + +class PathCommentsResponse(CamelCaseModel): + comments: List[Comment] + continuation_token: Optional[str] + type: str + path: str + like_count: int + comment_count: int + share_count: int From a9af6e976accd0b9e7518fb3eb9c4cd00ab0955b Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 10:05:50 -0400 Subject: [PATCH 28/44] fix comments.xboxlive.com methods --- xbox/webapi/api/provider/feed/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 2ba39429..6850caf6 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -295,16 +295,18 @@ async def get_post_summaries( url = self.COMMENTS_URL + f"/summaries/batch" resp = await self.client.session.post( - url, headers=self.COMMENTS_URL, json=data, **kwargs + url, headers=self.HEADERS_COMMENTS, json=data, **kwargs ) resp.raise_for_status() return SummariesResponse.parse_raw(resp.text).summaries async def get_post_comments(self, post_path: str, **kwargs) -> PathCommentsResponse: - url = self.COMMENTS_URL + f"/{post_path}" + url = self.COMMENTS_URL + f"/{post_path}/comments" - resp = await self.client.session.get(url, headers=self.COMMENTS_URL, **kwargs) + resp = await self.client.session.get( + url, headers=self.HEADERS_COMMENTS, **kwargs + ) resp.raise_for_status() return PathCommentsResponse.parse_raw(resp.text) From e3483c6f04d61d716c52738d0bd670c589e6edd2 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 28 Jun 2023 10:33:00 -0400 Subject: [PATCH 29/44] PathType enum for comments --- xbox/webapi/api/provider/feed/models.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index 99ece0c6..8ff850a9 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -74,6 +74,13 @@ class LinkType(str, Enum): DEFAULT = "Default" +class PathType(str, Enum): + UNKNOWN = "Unknown" + ACHIEVEMENT = "Achievement" + ACTIVITY_FEED_ITEM = "ActivityFeedItem" + USER_POST_TIMELINE = "UserPostTimeline" + + class TimelineType(str, Enum): UNKNOWN = "Unknown" USER = "User" @@ -201,7 +208,7 @@ class UserTimeline(Timeline): class PathSummary(CamelCaseModel): - type: str + type: PathType path: str like_count: int comment_count: int @@ -210,7 +217,7 @@ class PathSummary(CamelCaseModel): class Comment(CamelCaseModel): text: str - root_type: str + root_type: PathType root_path: str path: str xuid: str @@ -268,7 +275,7 @@ class Config: class AchievementActivityItem(ActivityItem): achievement_scid: UUID achievement_id: str - achievement_type: str + achievement_type: AchievementType achievement_icon: str achievement_name: str rarity_category: str @@ -368,7 +375,7 @@ class SummariesResponse(CamelCaseModel): class PathCommentsResponse(CamelCaseModel): comments: List[Comment] continuation_token: Optional[str] - type: str + type: PathType path: str like_count: int comment_count: int From f106ca691f0c8652d825f78d530f3854fee80f64 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 13:35:32 -0400 Subject: [PATCH 30/44] get_comment_alerts() --- xbox/webapi/api/provider/feed/__init__.py | 11 +++++ xbox/webapi/api/provider/feed/models.py | 49 +++++++++++++++++------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 6850caf6..52fdbb2e 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -14,6 +14,7 @@ from xbox.webapi.api.provider.feed.models import ( ActivityItemType, ActivityResponse, + CommentAlertsResponse, ContentType, Message, MessageResponse, @@ -311,6 +312,16 @@ async def get_post_comments(self, post_path: str, **kwargs) -> PathCommentsRespo return PathCommentsResponse.parse_raw(resp.text) + async def get_comment_alerts(self, **kwargs) -> CommentAlertsResponse: + headers = self.HEADERS_COMMENTS | {"x-xbl-contract-version": "4"} + + url = self.COMMENTS_URL + f"/users/xuid({self.client.xuid})/alerts" + + resp = await self.client.session.get(url, headers=headers, **kwargs) + resp.raise_for_status() + + return CommentAlertsResponse.parse_raw(resp.text) + # CHAT FEED # --------------------------------------------------------------------------- diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index 8ff850a9..1f2cb5f8 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -18,13 +18,16 @@ class ActivityItemType(str, Enum): FOLLOWED = "Followed" TEXT_POST = "TextPost" USER_POST = "UserPost" - ACHIEVEMENT = "Achievement" + ACHIEVEMENT = "Achievement" # +pathtype ACHIEVEMENT_LEGACY = "LegacyAchievement" - SCREENSHOT = "Screenshot" + SCREENSHOT = "Screenshot" # +pathtype + GAME_CLIP = "GameClip" # pathtype GAME_DVR = "GameDVR" BROADCAST_START = "BroadcastStart" BROADCAST_END = "BroadcastEnd" GAMERTAG_CHANGED = "GamertagChanged" + ACTIVITY_FEED_ITEM = "ActivityFeedItem" # pathtype + USER_POST_TIMELINE = "UserPostTimeline" # pathtype CONTAINER = "Container" @@ -74,13 +77,6 @@ class LinkType(str, Enum): DEFAULT = "Default" -class PathType(str, Enum): - UNKNOWN = "Unknown" - ACHIEVEMENT = "Achievement" - ACTIVITY_FEED_ITEM = "ActivityFeedItem" - USER_POST_TIMELINE = "UserPostTimeline" - - class TimelineType(str, Enum): UNKNOWN = "Unknown" USER = "User" @@ -98,6 +94,13 @@ class ItemSource(str, Enum): TRENDING = "Trending" +class PostAction(str, Enum): + UNKNOWN = "Unknown" + LIKE = "Like" + COMMENT = "Comment" + SHARE = "Share" + + class Platform(str, Enum): UNKNOWN = "Unknown" XBOX_360 = "Xenon" # Not observed @@ -207,8 +210,25 @@ class UserTimeline(Timeline): timeline_uri: str +class CommentAlert(CamelCaseModel): + id: str + action: PostAction + path: str + actor_xuid: str + actor_gamertag: str + parent_type: ActivityItemType + parent_path: str + owner_xuid: str + owner_gamertag: str + timestamp: datetime + seen: bool + text: Optional[str] + root_path: str + club_id: str + + class PathSummary(CamelCaseModel): - type: PathType + type: ActivityItemType # pathtype path: str like_count: int comment_count: int @@ -217,7 +237,7 @@ class PathSummary(CamelCaseModel): class Comment(CamelCaseModel): text: str - root_type: PathType + root_type: ActivityItemType # pathtype root_path: str path: str xuid: str @@ -345,6 +365,11 @@ class Config: smart_union = True +class CommentAlertsResponse(CamelCaseModel): + alerts: List[CommentAlert] + continuation_token: Optional[str] + + class MessageResponse(CamelCaseModel): message: Message @@ -375,7 +400,7 @@ class SummariesResponse(CamelCaseModel): class PathCommentsResponse(CamelCaseModel): comments: List[Comment] continuation_token: Optional[str] - type: PathType + type: ActivityItemType # pathtype path: str like_count: int comment_count: int From 186e8dd864ba8fb38a175ce8a19f6ecfbd1697af Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 13:41:07 -0400 Subject: [PATCH 31/44] rename feed_item_id parameters to item_locator --- xbox/webapi/api/provider/feed/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/xbox/webapi/api/provider/feed/__init__.py b/xbox/webapi/api/provider/feed/__init__.py index 52fdbb2e..6226f96d 100644 --- a/xbox/webapi/api/provider/feed/__init__.py +++ b/xbox/webapi/api/provider/feed/__init__.py @@ -43,10 +43,10 @@ class FeedProvider(BaseProvider): HEADERS_CHATFEED = {"x-xbl-contract-version": "1"} HEADERS_CLUBMODERATION = {"x-xbl-contract-version": "1"} - async def delete_feed_item(self, feed_item_id: str, **kwargs) -> None: + async def delete_feed_item(self, item_locator: str, **kwargs) -> None: headers = {"x-xbl-contract-version": "2"} - url = f"https://{feed_item_id}" + url = f"https://{item_locator}" resp = await self.client.session.delete(url, headers=headers, **kwargs) resp.raise_for_status() @@ -209,8 +209,8 @@ async def get_user_pins( return ActivityResponse.parse_raw(resp.text) - async def pin_post(self, feed_item_id: str, **kwargs) -> None: - data = {"locator": feed_item_id} + async def pin_item(self, item_locator: str, **kwargs) -> None: + data = {"locator": item_locator} url = self.ACTIVITY_URL + f"/timelines/User/{self.client.xuid}/pins" @@ -219,8 +219,8 @@ async def pin_post(self, feed_item_id: str, **kwargs) -> None: ) resp.raise_for_status() - async def unpin_post(self, feed_item_id: str, **kwargs) -> None: - data = {"locator": feed_item_id} + async def unpin_item(self, item_locator: str, **kwargs) -> None: + data = {"locator": item_locator} url = self.ACTIVITY_URL + f"/timelines/User/{self.client.xuid}/unpin" @@ -255,12 +255,12 @@ async def post_text(self, text: str, **kwargs) -> PostResponse: async def share_item( self, - feed_item_id: str, + item_locator: str, text: Optional[str] = None, parent_id: Optional[str] = None, **kwargs, ) -> PostResponse: - post_type_data = {"locator": feed_item_id} + post_type_data = {"locator": item_locator} if parent_id: post_type_data["parentId"] = parent_id From 83a0d117e070c5a3aa7c616f2bfc5ccbd5551578 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 14:19:44 -0400 Subject: [PATCH 32/44] Clean up club models.py comments --- xbox/webapi/api/provider/clubs/models.py | 154 ++++++++++------------- 1 file changed, 68 insertions(+), 86 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index cf6a54f4..fa0ff7b7 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -9,25 +9,24 @@ class ClubType(str, Enum): # From xbox::services::clubs::clubs_service_impl::convert_club_type_to_string # In Microsoft.Xbox.Services.dll - UNKNOWN = "unknown" # Unknown club type - PUBLIC = "open" # Open club - PRIVATE = "closed" # Closed club - HIDDEN = "secret" # Secret club + UNKNOWN = "unknown" + PUBLIC = "open" # Anyone can Find, Ask to Join, and Play. + PRIVATE = "closed" # Anyone can Find and Ask to Join. + HIDDEN = "secret" # Only invited people can Ask to Join. class ClubRole(str, Enum): # From xbox::services::clubs::clubs_service_impl::convert_club_role_to_string # In Microsoft.Xbox.Services.dll - NONMEMBER = "Nonmember" # Not a member of the club. Used exclusively for permissions/settings - MEMBER = "Member" # Member of a club - MODERATOR = "Moderator" # Moderator of a club - OWNER = "Owner" # Owner of a club - REQUESTED_TO_JOIN = "RequestedToJoin" # User has requested to join a club - RECOMMENDED = "Recommended" # User has been recommended for a club - INVITED = "Invited" # User has been invited to a club - BANNED = "Banned" # User has been banned from all interaction with a club. - # A user cannot have any other role with a club if they are banned from it - FOLLOWER = "Follower" # Follower of a club + NONMEMBER = "Nonmember" # Used exclusively for permissions/settings + MEMBER = "Member" + MODERATOR = "Moderator" + OWNER = "Owner" + REQUESTED_TO_JOIN = "RequestedToJoin" + RECOMMENDED = "Recommended" + INVITED = "Invited" + BANNED = "Banned" # A user cannot have any other role with a club if they are banned from it + FOLLOWER = "Follower" class ClubPresence(str, Enum): @@ -35,16 +34,14 @@ class ClubPresence(str, Enum): # In Microsoft.Xbox.Services.dll NOT_IN_CLUB = "NotInClub" # User is no longer on a club page. IN_CLUB = "InClub" # User is viewing the club, but not on any specific page. - CHAT = "Chat" # User is on the chat page. - FEED = "Feed" # User is viewing the club feed. - ROSTER = "Roster" # User is viewing the club roster/presence. - PLAY = ( - "Play" # User is on the play tab in the club (not actually playing anything). - ) + CHAT = "Chat" + FEED = "Feed" + ROSTER = "Roster" + PLAY = "Play" # User is on the play tab in the club (not actually playing). IN_GAME = "InGame" # User is playing the associated game. # Extra value in modern club implementation - IN_PARTY = "InParty" # UNDOCUMENTED -- UNCONFIRMED ENUM VALUE + IN_PARTY = "InParty" class ClubJoinability(str, Enum): @@ -141,30 +138,24 @@ class ClubsSuggestResultWithText(CamelCaseModel): class ClubTypeContainer(CamelCaseModel): type: ClubType - genre: str + genre: str # social localized_title_family_name: Optional[str] title_family_id: UUID class ClubRoleRecord(CamelCaseModel): actor_xuid: str # Actor Xuid that was responsible for user belonging to the role. - xuid: Optional[str] # Xuid that belongs to the role. Empty if same as actor_xuid. + xuid: Optional[str] # Null if same as actor_xuid. role: Optional[ClubRole] created_date: datetime # When the user was added to the role. - localized_role: Optional[ClubRole] # Role of the user. + localized_role: Optional[ClubRole] class ClubRoster(CamelCaseModel): - moderator: Optional[ - List[ClubRoleRecord] - ] # Club moderators, only empty if club is suspended. - requested_to_join: Optional[ - List[ClubRoleRecord] - ] # Users who've requested to join the club. - recommended: Optional[ - List[ClubRoleRecord] - ] # Users who've been recommended for the club. - banned: Optional[List[ClubRoleRecord]] # Users who've been banned from the club. + moderator: Optional[List[ClubRoleRecord]] # Includes Owner, null if suspended. + requested_to_join: Optional[List[ClubRoleRecord]] + recommended: Optional[List[ClubRoleRecord]] + banned: Optional[List[ClubRoleRecord]] class TargetRoleRecords(CamelCaseModel): @@ -173,7 +164,7 @@ class TargetRoleRecords(CamelCaseModel): class ClubRecommendationReason(CamelCaseModel): - localized_text: str # Localized string giving the reason the club is recommended + localized_text: str class ClubRecommendation(CamelCaseModel): @@ -201,9 +192,9 @@ class ClubSummary(CamelCaseModel): class ClubUserPresenceRecord(CamelCaseModel): - xuid: str # Xuid of the user who was present at the club. - last_seen_timestamp: datetime # Time when the user was last present within the club. - last_seen_state: ClubPresence # User's state when they were last seen. + xuid: str + last_seen_timestamp: datetime + last_seen_state: ClubPresence _VT = TypeVar("_VT") # Setting Value Generic @@ -272,62 +263,53 @@ class ClubRootSettings(CamelCaseModel): class ClubProfile(CamelCaseModel): - description: Setting[Optional[str]] # Description of the club - rules: Setting[Any] - name: Setting[str] # Name of the club - short_name: Setting[str] # Club short name - is_searchable: Setting[bool] # Should the club show up in search results - is_recommendable: Setting[bool] # Should the club show up in recommendations - leave_enabled: Setting[bool] # Can users leave the club - transfer_ownership_enabled: Setting[ - bool - ] # Can ownership of the club be transferred - mature_content_enabled: Setting[bool] # Is mature content enabled within the club - watch_club_titles_only: Setting[bool] - display_image_url: Setting[str] # URL for display image - background_image_url: Setting[str] # URL for background image - preferred_locale: Setting[str] # The club's preferred locale - tags: Setting[ - List[str] - ] # Tags associated with the club (ex. "Hate-Free", "Women only") - associated_titles: Setting[List[str]] # List of titles associated with the club - primary_color: Setting[str] # Primary color of the club - secondary_color: Setting[str] # Secondary color of the club - tertiary_color: Setting[str] # Tertiary color of the club + description: Setting[Optional[str]] + rules: Setting[Any] # Unknown. + name: Setting[str] + short_name: Setting[str] + is_searchable: Setting[bool] # Should the club show up in search results. + is_recommendable: Setting[bool] # Should the club show up in recommendations. + leave_enabled: Setting[bool] + transfer_ownership_enabled: Setting[bool] + mature_content_enabled: Setting[bool] + watch_club_titles_only: Setting[bool] # Unknown. + display_image_url: Setting[str] # Icon image URL. + background_image_url: Setting[str] + preferred_locale: Setting[str] # The club language. + tags: Setting[List[str]] # See xbox/webapi/api/provider/clubs/const.py for tags. + associated_titles: Setting[List[str]] # List of titles associated with the club. + primary_color: Setting[str] + secondary_color: Setting[str] + tertiary_color: Setting[str] class Club(CamelCaseModel): - id: str # ClubId - club_type: ClubTypeContainer # Type (visibility) of club - creation_date_utc: datetime # When the club was created. - glyph_image_url: Optional[str] # Club's display image url - banner_image_url: Optional[str] # Club's background image url - settings: Optional[ - ClubRootSettings - ] # Settings dictating what actions users can take - # within the club depending on their role. - followers_count: int # Number of followers of the club. - members_count: int # Number of club members. - moderators_count: int # Number of club moderators. - recommended_count: int # Configurable club attributes - requested_to_join_count: int # Number of users requesting to join the club. - club_presence_count: int # Count of members present in the club. - club_presence_today_count: int # Count of members present in the club. + id: str + club_type: ClubTypeContainer # Type of club, including genre. + creation_date_utc: datetime + glyph_image_url: Optional[str] # Icon image URL. + banner_image_url: Optional[str] # Background image url. + settings: Optional[ClubRootSettings] # Dictates what actions roles can take. + followers_count: int + members_count: int + moderators_count: int # Includes owner. + recommended_count: int + requested_to_join_count: int + club_presence_count: int # Amount of members currently in club. + club_presence_today_count: int # Amount of members who were in club today. club_presence_in_game_count: int roster: Optional[ClubRoster] target_roles: Optional[TargetRoleRecords] recommendation: Optional[ClubRecommendation] club_presence: Optional[List[ClubUserPresenceRecord]] state: ClubState - suspended_until_utc: Optional[ - datetime - ] # When the club remains suspended until. Null if not suspended + suspended_until_utc: Optional[datetime] # Null if not suspended. report_count: int # Number of reports for the club. - reported_items_count: int # Number of reported items for the club. + reported_items_count: int max_members_per_club: int max_members_in_game: int - owner_xuid: Optional[str] - founder_xuid: str # Club founder's Xuid. + owner_xuid: Optional[str] # Null if suspended. + founder_xuid: str title_deep_links: Optional[DeepLinks] profile: ClubProfile # Configurable club attributes is_official_club: bool @@ -344,12 +326,12 @@ class OwnedClubsResponse(CamelCaseModel): class SearchClubsResponse(CamelCaseModel): - clubs: List[Club] # List of clubs that match the search query - search_facet_results: Optional[ - ClubSearchFacetResults - ] # Facets can be used to further narrow down search results. + clubs: List[Club] + + # Facets can be used to further narrow down search results. # The return map maps a facet (ie. tag or title) to a collection of search facet result objects. # A search facet result object describes how often a particular value of that facet occurred. + search_facet_results: Optional[ClubSearchFacetResults] recommendation_counts: Optional[Any] club_deep_links: Optional[DeepLinks] From 3ef9e3e67f15a5add7652804e9d79d718a192a9e Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 14:27:17 -0400 Subject: [PATCH 33/44] ClubGenre enum --- xbox/webapi/api/provider/clubs/__init__.py | 5 +++-- xbox/webapi/api/provider/clubs/models.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 5e005a20..c947d75f 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -11,6 +11,7 @@ from xbox.webapi.api.provider.baseprovider import BaseProvider from xbox.webapi.api.provider.clubs.models import ( Club, + ClubGenre, ClubPresence, ClubReservation, ClubRole, @@ -112,7 +113,7 @@ async def create_club( self, name: str, club_type: ClubType, - genre: str = "social", + genre: ClubGenre = ClubGenre.SOCIAL, title_family_id: UUID = _NULL_UUID, **kwargs, ) -> ClubSummary: @@ -130,7 +131,7 @@ async def create_club( called claim_club_name(). - 1023: The requested club name was rejected. - 1038: A TitleFamilyId value must be specified when requesting a TitleClub - (genre is "title" but title_family_id is not provided). + (genre is ClubGenre.TITLE but title_family_id is not provided). - 1041: The calling title is not authorized to perform the requested action with the requested TitleFamilyId - 1042: The club genre is not valid. """ diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index fa0ff7b7..b12c2588 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -44,6 +44,12 @@ class ClubPresence(str, Enum): IN_PARTY = "InParty" +class ClubGenre(str, Enum): + UNKNOWN = "unknown" + SOCIAL = "social" # User club + TITLE = "title" # Official game club + + class ClubJoinability(str, Enum): UNKNOWN = "Unknown" REQUEST_TO_JOIN = "OpenJoin" @@ -138,7 +144,7 @@ class ClubsSuggestResultWithText(CamelCaseModel): class ClubTypeContainer(CamelCaseModel): type: ClubType - genre: str # social + genre: ClubGenre localized_title_family_name: Optional[str] title_family_id: UUID @@ -188,7 +194,7 @@ class ClubSummary(CamelCaseModel): free_name_change: Optional[bool] can_delete_immediately: bool suspension_required_after: Optional[datetime] - genre: str + genre: ClubGenre class ClubUserPresenceRecord(CamelCaseModel): From da42ad5e679924a21d4dbdf5e9e602da2c5e75ac Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 15:26:06 -0400 Subject: [PATCH 34/44] add club default settings to clubs/const.py --- xbox/webapi/api/provider/clubs/const.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/const.py b/xbox/webapi/api/provider/clubs/const.py index 01bfcde5..b68c305a 100644 --- a/xbox/webapi/api/provider/clubs/const.py +++ b/xbox/webapi/api/provider/clubs/const.py @@ -1,6 +1,28 @@ """Web API Constants.""" -from typing import Final +from typing import Final, Dict, Union + +from xbox.webapi.api.provider.clubs.models import ClubRole + +DEFAULT_SETTINGS_OPEN: Final[Dict[str, Union[str, bool]]] = { + "preferred_locale": "en-US", + "request_to_join_enabled": True, + "who_can_post_to_feed": ClubRole.MEMBER, + "who_can_invite": ClubRole.MODERATOR, + "who_can_chat": ClubRole.MEMBER, + "who_can_create_lfg": ClubRole.MEMBER, + "who_can_join_lfg": ClubRole.NONMEMBER, + "mature_content_enabled": True, + "watch_club_titles_only": False, +} + +DEFAULT_SETTINGS_CLOSED: Final[Dict[str, Union[str, bool]]] = DEFAULT_SETTINGS_OPEN.copy() + +DEFAULT_SETTINGS_SECRET: Final[Dict[str, Union[str, bool]]] = DEFAULT_SETTINGS_OPEN | { + "request_to_join_enabled": False, + "who_can_join_lfg": ClubRole.MEMBER, +} + COMMUNICATION_TAGS: Final[frozenset] = frozenset( ( From ffca6c933017d8f8a11600ee7d108578ea4f6196 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Thu, 29 Jun 2023 15:26:29 -0400 Subject: [PATCH 35/44] add RequestToJoin to ClubJoinability enum --- xbox/webapi/api/provider/clubs/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index b12c2588..55bcf7f0 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -52,7 +52,8 @@ class ClubGenre(str, Enum): class ClubJoinability(str, Enum): UNKNOWN = "Unknown" - REQUEST_TO_JOIN = "OpenJoin" + OPEN = "OpenJoin" + REQUEST_TO_JOIN = "RequestToJoin" INVITE_ONLY = "InviteOnly" @@ -102,8 +103,8 @@ class ClubSettingsContract(CamelCaseModel): who_can_chat: Optional[ClubRole] who_can_create_lfg: Optional[ClubRole] who_can_join_lfg: Optional[ClubRole] - mature_content_enabled: Optional[bool] - watch_club_titles_only: Optional[bool] + mature_content_enabled: Optional[bool] # Streams marked as mature + watch_club_titles_only: Optional[bool] # Streams of club games only get_recommendation_enabled: Optional[bool] search_enabled: Optional[bool] delete_enabled: Optional[bool] From e872026bd045d0ba038627dfaa74711954e18e31 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Fri, 30 Jun 2023 23:51:26 -0400 Subject: [PATCH 36/44] add USER_POST_TIMELINE_CHANNEL enum value for club channel posts --- xbox/webapi/api/provider/feed/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/xbox/webapi/api/provider/feed/models.py b/xbox/webapi/api/provider/feed/models.py index 1f2cb5f8..08585c80 100644 --- a/xbox/webapi/api/provider/feed/models.py +++ b/xbox/webapi/api/provider/feed/models.py @@ -28,6 +28,7 @@ class ActivityItemType(str, Enum): GAMERTAG_CHANGED = "GamertagChanged" ACTIVITY_FEED_ITEM = "ActivityFeedItem" # pathtype USER_POST_TIMELINE = "UserPostTimeline" # pathtype + USER_POST_TIMELINE_CHANNEL = "UserPostTimelineChannel" # pathtype CONTAINER = "Container" From 88f13c9b82b8a8aef5f71d2de9cc7bea7bd9015f Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Fri, 30 Jun 2023 23:53:09 -0400 Subject: [PATCH 37/44] add suspend_club() and unsuspend_club() --- xbox/webapi/api/provider/clubs/__init__.py | 46 +++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index c947d75f..8216052e 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -3,8 +3,9 @@ Manage clubs and club information. """ -from collections.abc import Sequence import json +from collections.abc import Sequence +from datetime import datetime from typing import Dict, List, Optional, Union from uuid import UUID @@ -195,7 +196,7 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: return ClubSummary.parse_raw(resp.text) async def delete_club( - self, club_id: str, actor: Optional[str] = None, **kwargs + self, club_id: str, **kwargs ) -> Optional[ClubReservation]: """Delete the club with the given id. @@ -207,11 +208,8 @@ async def delete_club( Codes - 204: Successfully deleted club. - 409: Another pending operation in progress. - - 1021: The actor specified for the suspension record is not valid. """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" - if actor: - url += f"/suspension/{actor}" resp = await self.client.session.delete( url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs @@ -221,6 +219,44 @@ async def delete_club( if resp.text: return ClubReservation.parse_raw(resp.text) + async def suspend_club( + self, club_id: str, delete_date: datetime, **kwargs + ) -> None: + """Delete the club with the given id after the given date. + + The club is suspended in the meantime and can be restored through unsuspend_club(). + + Codes + - 204: Successfully deleted club. + - 1021: The actor specified for the suspension record is not valid. + """ + data = {'actor': 'owner', 'deleteAfter': delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")} + + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})/suspension/owner" + + resp = await self.client.session.put( + url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + ) + resp.raise_for_status() + + async def unsuspend_club( + self, club_id: str, **kwargs + ) -> None: + """Stop the club deletion & suspension process. + + Codes + - 204: Successfully unsuspended club. + - 1021: The actor specified for the suspension record is not valid. + """ + url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})/suspension/owner" + + resp = await self.client.session.delete( + url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs + ) + resp.raise_for_status() + + return resp + # CLUB HUB # --------------------------------------------------------------------------- From a163eb540489183b1302f1e11b600be3fe9b7e55 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Sat, 1 Jul 2023 00:23:20 -0400 Subject: [PATCH 38/44] add ClubSuspension model --- xbox/webapi/api/provider/clubs/__init__.py | 15 +++++++++------ xbox/webapi/api/provider/clubs/models.py | 21 ++++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 8216052e..d4a050d2 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -3,9 +3,9 @@ Manage clubs and club information. """ -import json from collections.abc import Sequence from datetime import datetime +import json from typing import Dict, List, Optional, Union from uuid import UUID @@ -20,6 +20,7 @@ ClubRoster, ClubSettingsContract, ClubSummary, + ClubSuspension, ClubType, ClubUserPresenceRecord, GetPresenceResponse, @@ -200,12 +201,14 @@ async def delete_club( ) -> Optional[ClubReservation]: """Delete the club with the given id. - If a club is not hidden and is older than one week you will receive a ClubReservation for the club name, + If a club is not hidden and is older than one week you will receive a reservation for the club name, and it will be suspended for 7 days before being automatically deleted. - The ClubReservation should last for an hour after the club is deleted. + The reservation should last for 1 day after the club is deleted, but you can double check in the ClubSummary + reservation_duration_after_suspension_in_hours field. Codes + - 202: Successfully started suspension process. - 204: Successfully deleted club. - 409: Another pending operation in progress. """ @@ -217,7 +220,7 @@ async def delete_club( resp.raise_for_status() if resp.text: - return ClubReservation.parse_raw(resp.text) + return ClubSummary.parse_raw(resp.text) async def suspend_club( self, club_id: str, delete_date: datetime, **kwargs @@ -230,12 +233,12 @@ async def suspend_club( - 204: Successfully deleted club. - 1021: The actor specified for the suspension record is not valid. """ - data = {'actor': 'owner', 'deleteAfter': delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")} + suspension = ClubSuspension.parse_obj({'deleteAfter': delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}) url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})/suspension/owner" resp = await self.client.session.put( - url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs + url, headers=self.HEADERS_CLUBACCOUNTS, json=json.loads(suspension.json()), **kwargs ) resp.raise_for_status() diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 55bcf7f0..57544ca8 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -88,14 +88,14 @@ class ClubSettingsContract(CamelCaseModel): background_image_url: Optional[str] display_image_url: Optional[str] preferred_color: Optional[PreferredColor] - activity_feed_enabled: Optional[bool] - chat_enabled: Optional[bool] - lfg_enabled: Optional[bool] + activity_feed_enabled: Optional[bool] # Permanent change + chat_enabled: Optional[bool] # Permanent change + lfg_enabled: Optional[bool] # Cannot be modified preferred_locale: Optional[str] request_to_join_enabled: Optional[bool] leave_enabled: Optional[bool] transfer_ownership_enabled: Optional[bool] - is_promoted_club: Optional[bool] + is_promoted_club: Optional[bool] # Cannot be modified tags: Optional[List[str]] titles: Optional[List[str]] who_can_post_to_feed: Optional[ClubRole] @@ -105,9 +105,9 @@ class ClubSettingsContract(CamelCaseModel): who_can_join_lfg: Optional[ClubRole] mature_content_enabled: Optional[bool] # Streams marked as mature watch_club_titles_only: Optional[bool] # Streams of club games only - get_recommendation_enabled: Optional[bool] - search_enabled: Optional[bool] - delete_enabled: Optional[bool] + get_recommendation_enabled: Optional[bool] # Permanent change + search_enabled: Optional[bool] # Permanent change + delete_enabled: Optional[bool] # Cannot be modified rename_enabled: Optional[bool] joinability: Optional[ClubJoinability] @@ -186,15 +186,22 @@ class ClubReservation(CamelCaseModel): expires: datetime +class ClubSuspension(CamelCaseModel): + actor: str = 'owner' + delete_after: datetime + + class ClubSummary(CamelCaseModel): name: str owner: str id: str type: ClubType created: datetime + suspensions: Optional[List[ClubSuspension]] free_name_change: Optional[bool] can_delete_immediately: bool suspension_required_after: Optional[datetime] + reservation_duration_after_suspension_in_hours: Optional[int] genre: ClubGenre From 9428118e355928dac45ecb6b505eaced440f9053 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Sat, 1 Jul 2023 00:27:15 -0400 Subject: [PATCH 39/44] black reformat --- xbox/webapi/api/provider/clubs/__init__.py | 21 ++++++++++----------- xbox/webapi/api/provider/clubs/const.py | 6 ++++-- xbox/webapi/api/provider/clubs/models.py | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index d4a050d2..c318d769 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -196,9 +196,7 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: return ClubSummary.parse_raw(resp.text) - async def delete_club( - self, club_id: str, **kwargs - ) -> Optional[ClubReservation]: + async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: """Delete the club with the given id. If a club is not hidden and is older than one week you will receive a reservation for the club name, @@ -222,9 +220,7 @@ async def delete_club( if resp.text: return ClubSummary.parse_raw(resp.text) - async def suspend_club( - self, club_id: str, delete_date: datetime, **kwargs - ) -> None: + async def suspend_club(self, club_id: str, delete_date: datetime, **kwargs) -> None: """Delete the club with the given id after the given date. The club is suspended in the meantime and can be restored through unsuspend_club(). @@ -233,18 +229,21 @@ async def suspend_club( - 204: Successfully deleted club. - 1021: The actor specified for the suspension record is not valid. """ - suspension = ClubSuspension.parse_obj({'deleteAfter': delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}) + suspension = ClubSuspension.parse_obj( + {"deleteAfter": delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")} + ) url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})/suspension/owner" resp = await self.client.session.put( - url, headers=self.HEADERS_CLUBACCOUNTS, json=json.loads(suspension.json()), **kwargs + url, + headers=self.HEADERS_CLUBACCOUNTS, + json=json.loads(suspension.json()), + **kwargs, ) resp.raise_for_status() - async def unsuspend_club( - self, club_id: str, **kwargs - ) -> None: + async def unsuspend_club(self, club_id: str, **kwargs) -> None: """Stop the club deletion & suspension process. Codes diff --git a/xbox/webapi/api/provider/clubs/const.py b/xbox/webapi/api/provider/clubs/const.py index b68c305a..0276e6e5 100644 --- a/xbox/webapi/api/provider/clubs/const.py +++ b/xbox/webapi/api/provider/clubs/const.py @@ -1,6 +1,6 @@ """Web API Constants.""" -from typing import Final, Dict, Union +from typing import Dict, Final, Union from xbox.webapi.api.provider.clubs.models import ClubRole @@ -16,7 +16,9 @@ "watch_club_titles_only": False, } -DEFAULT_SETTINGS_CLOSED: Final[Dict[str, Union[str, bool]]] = DEFAULT_SETTINGS_OPEN.copy() +DEFAULT_SETTINGS_CLOSED: Final[ + Dict[str, Union[str, bool]] +] = DEFAULT_SETTINGS_OPEN.copy() DEFAULT_SETTINGS_SECRET: Final[Dict[str, Union[str, bool]]] = DEFAULT_SETTINGS_OPEN | { "request_to_join_enabled": False, diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index 57544ca8..c262d42b 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -187,7 +187,7 @@ class ClubReservation(CamelCaseModel): class ClubSuspension(CamelCaseModel): - actor: str = 'owner' + actor: str = "owner" delete_after: datetime From a72128cc51ca4a001368d97cfee8f6b2a0a49733 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Sat, 1 Jul 2023 00:33:11 -0400 Subject: [PATCH 40/44] add doc and remove unsuspend_club() return --- xbox/webapi/api/provider/clubs/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index c318d769..dbd2e7d8 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -224,6 +224,7 @@ async def suspend_club(self, club_id: str, delete_date: datetime, **kwargs) -> N """Delete the club with the given id after the given date. The club is suspended in the meantime and can be restored through unsuspend_club(). + The minimum delete_date is 168 hours (7 days) from the current time. Codes - 204: Successfully deleted club. @@ -257,8 +258,6 @@ async def unsuspend_club(self, club_id: str, **kwargs) -> None: ) resp.raise_for_status() - return resp - # CLUB HUB # --------------------------------------------------------------------------- From dc43a4a4b768de4c99572afaf3831310805f58f0 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Mon, 3 Jul 2023 05:58:43 -0400 Subject: [PATCH 41/44] clubs provider documentation revamp --- xbox/webapi/api/provider/clubs/__init__.py | 403 +++++++++++++++++---- 1 file changed, 323 insertions(+), 80 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index dbd2e7d8..2bda3a07 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -58,19 +58,24 @@ class ClubProvider(BaseProvider): # CLUB ACCOUNTS # --------------------------------------------------------------------------- - async def get_club_summary( - self, club_id: str, actor: Optional[str] = None, **kwargs - ) -> ClubSummary: - """Get a summary of a given club's information. + async def get_club_summary(self, club_id: str, **kwargs) -> ClubSummary: + """ + Get a summary of a given club's information and suspensions. You must own the club to use this method. - Codes - - 1021: The actor specified for the suspension record is not valid. + XLE error codes: + 200 - Successfully obtained club summary. + 1001 - A requested object was not found by the service. + 1016 - Query parameter was malformed or invalid. + + Args: + club_id: Club ID + + Returns: + :class:`ClubSummary`: Club Summary """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" - if actor: - url += f"/suspension/{actor}" resp = await self.client.session.get( url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs @@ -80,7 +85,15 @@ async def get_club_summary( return ClubSummary.parse_raw(resp.text) async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: - """Get list of clubs owned by the caller.""" + """ + Get list of clubs owned by the caller, along with how many clubs you can own. + + XLE error codes: + 200 - Successfully obtained owned clubs. + + Returns: + :class:`OwnedClubsResponse`: Owned Clubs Response + """ headers = self.HEADERS_CLUBACCOUNTS | {"x-xbl-contract-version": "2"} url = self.CLUBACCOUNTS_URL + f"/users/xuid({self.client.xuid})/clubsowned" @@ -91,15 +104,22 @@ async def get_clubs_owned(self, **kwargs) -> OwnedClubsResponse: return OwnedClubsResponse.parse_raw(resp.text) async def claim_club_name(self, name: str, **kwargs) -> ClubReservation: - """Reserve a club name for use in create_club(). + """ + Reserve a club name for use in create_club(). - Codes - - 200: Successfully claimed club. - - 1000: A parallel write operation took precedence over your request. - - 1007: The requested club name contains invalid characters. + XLE error codes: + 200 - Successfully claimed club. + 1005 - The requested club name was too long. + 1007 - The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - - 1010: The requested club name is not available. - - 1023: The requested club name was rejected. + 1010 - The requested club name is not available. + 1023 - The requested club name was rejected. + + Args: + name: Club name to claim + + Returns: + :class:`ClubReservation`: Club Reservation """ data = {"name": name} @@ -109,6 +129,7 @@ async def claim_club_name(self, name: str, **kwargs) -> ClubReservation: url, headers=self.HEADERS_CLUBACCOUNTS, json=data, **kwargs ) resp.raise_for_status() + return ClubReservation.parse_raw(resp.text) async def create_club( @@ -119,23 +140,35 @@ async def create_club( title_family_id: UUID = _NULL_UUID, **kwargs, ) -> ClubSummary: - """Create a club with the given name and visibility. + """ + Create a club with the given name and visibility. - If creating a public club, you must first call claim_club_name() with the name you want to use. + If creating a non-hidden club, you must first call claim_club_name() with the name you want to use. - Codes - - 201: Successfully created club. - - 409: Another pending operation in progress. - - 1007: The requested club name contains invalid characters. + XLE error codes: + 201 - Successfully created club. + 1000 - A parallel write operation took precedence over your request. + 1005 - The requested club name was too long. + 1007 - The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - - 1014: The club name has not been reserved by the calling user. + 1014 - The club name has not been reserved by the calling user. This happens when club_type is not HIDDEN and you have not called claim_club_name(). - - 1023: The requested club name was rejected. - - 1038: A TitleFamilyId value must be specified when requesting a TitleClub + 1023 - The requested club name was rejected. + 1038 - A TitleFamilyId value must be specified when requesting a TitleClub (genre is ClubGenre.TITLE but title_family_id is not provided). - - 1041: The calling title is not authorized to perform the requested action with the requested TitleFamilyId - - 1042: The club genre is not valid. + 1040 - An invalid TitleFamilyId value was specified. + 1041 - The calling title is not authorized to perform the requested action with the requested TitleFamilyId. + 1042 - The club genre is not valid. + + Args: + name: Club name + club_type: Club visibility + genre: Club genre, e.g. social or title + title_family_id: ID used to create titleclub with + + Returns: + :class:`ClubSummary`: Club Summary """ data = {"name": name, "type": club_type, "genre": genre} if title_family_id.int: @@ -153,10 +186,20 @@ async def create_club( async def transfer_club_ownership( self, club_id: str, xuid: str, **kwargs ) -> ClubSummary: - """Transfer club ownership to the given xuid. + """ + Transfer club ownership to the given xuid. - Codes - - 1015: The requested club is not available. + XLE error codes: + 200 - Successfully transferred ownership. + 1015 - The requested club is not available. + 1033 - The target user for the ownership transfer must already be a moderator of the club. + + Args: + club_id: Club ID + xuid: User to transfer ownership to + + Returns: + :class:`ClubSummary`: Club Summary """ data = {"method": "TransferOwnership", "user": xuid} @@ -170,20 +213,28 @@ async def transfer_club_ownership( return ClubSummary.parse_raw(resp.text) async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: - """Rename a club with the given name. + """ + Rename a club with the given name. A club can only be renamed once. - Codes - - 201: Successfully created club. - - 409: Another pending operation in progress. - - 1007: The requested club name contains invalid characters. + XLE error codes: + 201 - Successfully created club. + 1007 - The requested club name contains invalid characters. Club names must only use letter, numbers, and spaces. - - 1014: The club name has not been reserved by the calling user. + 1014 - The club name has not been reserved by the calling user. This happens when club_type is not HIDDEN and you have not called claim_club_name(). - - 1023: The requested club name was rejected. - - 1035: The name cannot be changed for the requested club. All available name changes have been used. + 1015 - The requested club is not available. + 1023 - The requested club name was rejected. + 1035 - The name cannot be changed for the requested club. All available name changes have been used. + + Args: + club_id: Club ID + name: Club name to use + + Returns: + :class:`ClubSummary`: Club Summary """ data = {"method": "ChangeName", "name": name} @@ -197,7 +248,8 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: return ClubSummary.parse_raw(resp.text) async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: - """Delete the club with the given id. + """ + Delete the club with the given id. If a club is not hidden and is older than one week you will receive a reservation for the club name, and it will be suspended for 7 days before being automatically deleted. @@ -205,10 +257,17 @@ async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: The reservation should last for 1 day after the club is deleted, but you can double check in the ClubSummary reservation_duration_after_suspension_in_hours field. - Codes - - 202: Successfully started suspension process. - - 204: Successfully deleted club. - - 409: Another pending operation in progress. + XLE error codes: + 202 - Successfully started suspension process. + 204 - Successfully deleted club. + 1015 - The requested club is not available. + 1053 - Another pending operation in progress. + + Args: + club_id: Club ID + + Returns: + :class:`Optional[ClubSummary]`: Club Summary if a suspension process is started, else None """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" @@ -220,15 +279,26 @@ async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: if resp.text: return ClubSummary.parse_raw(resp.text) - async def suspend_club(self, club_id: str, delete_date: datetime, **kwargs) -> None: - """Delete the club with the given id after the given date. + async def suspend_club( + self, club_id: str, delete_date: datetime, **kwargs + ) -> ClubSuspension: + """ + Delete the club with the given id after the given date. The club is suspended in the meantime and can be restored through unsuspend_club(). - The minimum delete_date is 168 hours (7 days) from the current time. - Codes - - 204: Successfully deleted club. - - 1021: The actor specified for the suspension record is not valid. + XLE error codes: + 200 - Successfully started suspension process. + 1015 - The requested club is not available. + 1018 - The caller is not permitted to perform the requested action. + 1021 - The actor specified for the suspension record is not valid. + + Args: + club_id: Club ID + delete_date: Date to end suspension and delete the club. Minimum is 168 hours (7 days) from the current time + + Returns: + :class:`ClubSuspension`: Club Suspension """ suspension = ClubSuspension.parse_obj( {"deleteAfter": delete_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ")} @@ -244,15 +314,22 @@ async def suspend_club(self, club_id: str, delete_date: datetime, **kwargs) -> N ) resp.raise_for_status() + return ClubSuspension.parse_raw(resp.text) + async def unsuspend_club(self, club_id: str, **kwargs) -> None: - """Stop the club deletion & suspension process. + """ + Stop the club suspension & deletion process. + + XLE error codes: + 204 - Successfully unsuspended club. + 1015 - The requested club is not available. + 1018 - The caller is not permitted to perform the requested action. + 1021 - The actor specified for the suspension record is not valid. - Codes - - 204: Successfully unsuspended club. - - 1021: The actor specified for the suspension record is not valid. + Args: + club_id: Club ID """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})/suspension/owner" - resp = await self.client.session.delete( url, headers=self.HEADERS_CLUBACCOUNTS, **kwargs ) @@ -301,24 +378,58 @@ def _create_clubhub_id_endpoint( async def _send_clubhub_decoration_request( self, club_ids: Union[str, List[str]], decorations: List[str], **kwargs ) -> SearchClubsResponse: + """ + Send a clubhub request with the given decorations and return the response. + + XLE error codes: + 200 - Successfully got Clubs. + 1018 - User not permitted to perform the requested action. + + Args: + club_ids: List of club IDs + decorations: URI decorations to specify extra information to request. + + Returns: + :class:`List[Club]`: List of Clubs + """ url = self._create_clubhub_id_endpoint(club_ids, decorations=decorations) + resp = await self.client.session.get( url, headers=self.HEADERS_CLUBHUB, **kwargs ) resp.raise_for_status() + return SearchClubsResponse.parse_raw(resp.text) async def get_club( self, club_id: str, decorations: Optional[List[str]] = None, **kwargs ) -> Club: - """Get a club through its id.""" + """ + Get a club through its id. + + Args: + club_id: Club ID + decorations: URI decorations to specify extra information to request. + + Returns: + :class:`List[Club]`: List of Clubs + """ return (await self.get_clubs([club_id], decorations, **kwargs))[0] async def get_clubs( self, club_ids: List[str], decorations: Optional[List[str]] = None, **kwargs ) -> List[Club]: - """Get club through their ids.""" + """ + Get clubs through their ids. + + Args: + club_ids: List of club IDs + decorations: URI decorations to specify extra information to request. + + Returns: + :class:`List[Club]`: List of Clubs + """ if decorations is None: decorations = [ @@ -340,7 +451,19 @@ async def get_clubs( async def get_club_associations( self, xuid: Optional[str] = None, **kwargs ) -> List[Club]: - """Get clubs associated with the given xuid.""" + """ + Get clubs associated with the given xuid. + + XLE error codes: + 200 - Successfully obtained club associations. + 1018 - User not permitted to perform the requested action. + + Args: + xuid: User to get clubs for, defaults to caller. + + Returns: + :class:`List[Club]`: List of Clubs + """ xuid = xuid or self.client.xuid url = self._create_clubhub_id_endpoint( @@ -350,12 +473,25 @@ async def get_club_associations( url, headers=self.HEADERS_CLUBHUB, **kwargs ) resp.raise_for_status() + return [club for club in SearchClubsResponse.parse_raw(resp.text).clubs] async def get_club_recommendations( self, title_id: Optional[str] = None, **kwargs ) -> List[Club]: - """Get clubs recommendations for the caller.""" + """ + Get clubs recommendations for the caller. + + XLE error codes: + 200 - Successfully obtained club recommendations. + 1023 - Failed to get club recommendations. + + Args: + title_id: If provided, get recommendation for the given title + + Returns: + :class:`List[Club]`: List of Clubs + """ method = self.client.session.post endpoint = "/clubs/recommendations" @@ -378,6 +514,22 @@ async def search_clubs( count: Optional[int] = None, **kwargs, ) -> SearchClubsResponse: + """ + Search for clubs with the given query. + + XLE error codes: + 200 - Successful obtained club search results. + 1024 - Failed to get club search results. + + Args: + query: Search query that looks at club names/descriptions + titles: Title IDs that clubs must be associated with + tags: Tags that clubs must have + count: How many clubs to obtain + + Returns: + :class:`SearchClubsResponse`: Search Clubs Response + """ params = self._create_search_params( query, titles=titles, tags=tags, count=count ) @@ -387,17 +539,32 @@ async def search_clubs( url, headers=self.HEADERS_CLUBHUB, params=params or None, **kwargs ) resp.raise_for_status() + return SearchClubsResponse.parse_raw(resp.text) # CLUB PRESENCE # --------------------------------------------------------------------------- async def get_presence_counts(self, club_id: str, **kwargs) -> GetPresenceResponse: + """ + Get presence counts for the given club id. + + XLE error codes: + 200 - Successful obtained club presence counts. + 1005 - ClubId is malformed or not within the valid range. + + Args: + club_id: Club ID + + Returns: + :class:`GetPresenceResponse`: Get Presence Response + """ url = self.CLUBPRESENCE_URL + f"/clubs/{club_id}/users/count" resp = await self.client.session.get( url, headers=self.HEADERS_CLUBPRESENCE, **kwargs ) resp.raise_for_status() + return GetPresenceResponse.parse_raw(resp.text) async def set_presence_within_club( @@ -406,8 +573,16 @@ async def set_presence_within_club( """Set your presence in a clubs to the given ClubPresence value. Codes: - - 204: Successfully changed presence. - - 1004: The claims are invalid. + 204 - Successfully changed presence. + 1004 - The claims are invalid. + 1005 - ClubId is malformed or not within the valid range. + 1006 - Request payload was not understood by the service. + 1006 - Identity used in the request URL was malformed + + Args: + club_id: Club ID + xuid: Xbox user ID to use + presence: Presence to set; InGame and InParty do not seem to work through this API """ # Microsoft.Xbox.Services.dll --- xbox::services::clubs::clubs::set_presence_within_club data = {"userPresenceState": presence} @@ -416,20 +591,28 @@ async def set_presence_within_club( resp = await self.client.session.post( url, headers=self.HEADERS_CLUBPRESENCE, json=data, **kwargs ) - resp.raise_for_status() + return resp.status == 204 # CLUB PROFILE # --------------------------------------------------------------------------- + async def update_club_profile(self, club_id: str, **setting_values) -> None: """Update club profile settings. - Settings are passed in as kwarg pairs. Each setting name must be a valid ClubSettingsContract field. - All kwargs that fail are passed in as an HTTP kwarg. + Settings are passed in as kwarg pairs. + + XLE error codes: + 200 - Successfully updated club profile. + 413 - Description is too large (500 char max). + 1004 - Unable to parse the request. + 1006 - Text Moderation Failed to validate setting. + 1100 - Insufficient permissions for write request. - Codes - - 413: Description is too large (500 char max). - - 1100: Insufficient permissions for write request. + Args: + club_id: Club ID + setting_values: Each setting name must be a valid ClubSettingsContract field + All values that fail are passed in as an HTTP kwarg """ contract = ClubSettingsContract() modified_fields = [] @@ -462,13 +645,32 @@ async def update_club_profile(self, club_id: str, **setting_values) -> None: # CLUB ROSTER # --------------------------------------------------------------------------- + async def _update_users_club_roles( self, club_id: str, xuid: str, advance: bool, **kwargs ) -> UpdateRolesResponse: - """Add or remove a xuid from a club id. - - Codes - - 1013: Cannot remove owner from the clubs. + """ + Add or remove an xuid from a club id. + + Affects the following roles: + ClubRole.MEMBER + ClubRole.REQUESTED_TO_JOIN + ClubRole.RECOMMENDED + ClubRole.INVITED + + XLE error codes: + 200 - Successfully updated user's club role. + 1013 - Cannot remove owner from the clubs. + 1016 - Club has disabled join requests or the club is secret. + 1019 - Target Club has been suspended. + + Args: + club_id: Club ID + xuid: Xbox user ID + advance: Whether to add or remove to club + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response """ url = self.CLUBROSTER_URL + f"/clubs/{club_id}/users/xuid({xuid})" if advance: @@ -477,21 +679,37 @@ async def _update_users_club_roles( method = self.client.session.delete resp = await method(url, headers=self.HEADERS_CLUBROSTER, **kwargs) - resp.raise_for_status() + return UpdateRolesResponse.parse_raw(resp.text) async def _set_users_club_roles( self, club_id: str, xuid: str, role: ClubRole, add_role: bool, **kwargs ) -> UpdateRolesResponse: - """Add or remove a club role from a xuid. - - Codes - - 1001: Caller role insufficient to perform the requested action. - - 1005: Contract version header was missing or invalid. - - 1008: Request payload was not understood by the service. - - 1011: Requested roles cannot be explicitly modified. - - 1012: Cannot modify ban status due to permissions or request format. + """ + Add or remove a club role from an xuid. + + Can only modify the following roles: + ClubRole.MODERATOR + ClubRole.BANNED + ClubRole.FOLLOWER + + XLE error codes: + 1001 - Caller role insufficient to perform the requested action. + 1003 - Target club does not exist. + 1008 - Request payload was not understood by the service. + 1011 - Requested roles cannot be explicitly modified. + 1012 - Cannot modify ban status due to permissions or request format. + 1015 - Cannot modify follow status due to permissions or request format. + + Args: + club_id: Club ID + xuid: Xbox user ID + role: Club role to modify + add_role: Whether to add or remove role from user + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response """ data = {} url = self.CLUBROSTER_URL + f"/clubs/{club_id}/users/xuid({xuid})/roles" @@ -503,13 +721,29 @@ async def _set_users_club_roles( url += f"/{role}" resp = await method(url, headers=self.HEADERS_CLUBROSTER, json=data, **kwargs) - resp.raise_for_status() + return UpdateRolesResponse.parse_raw(resp.text) async def add_user_to_club( self, club_id: str, xuid: Optional[str] = None, **kwargs ) -> UpdateRolesResponse: + """Add user to the given club. + + This can result in the following roles being modified: + ClubRole.FOLLOWER - Given after joining + ClubRole.MEMBER - Given after joining + ClubRole.REQUESTED_TO_JOIN - Given after requesting to join a club + ClubRole.RECOMMENDED - Given after being recommended by a club member + ClubRole.INVITED - Given after being invited by a club member or moderator + + Args: + club_id: Club ID + xuid: Xbox user ID. If not provided, defaults to caller xuid + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ xuid = xuid or self.client.xuid return await self._update_users_club_roles( @@ -519,6 +753,15 @@ async def add_user_to_club( async def remove_user_from_club( self, club_id: str, xuid: Optional[str] = None, **kwargs ) -> UpdateRolesResponse: + """Remove user from the given club. + + Args: + club_id: Club ID + xuid: Xbox user ID. If not provided, defaults to caller xuid + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ xuid = xuid or self.client.xuid return await self._update_users_club_roles( From 6448022882bc2f13b864c71fa3e8ed0a715d996e Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Mon, 3 Jul 2023 18:31:24 -0400 Subject: [PATCH 42/44] add support for delete_club() 200 response --- xbox/webapi/api/provider/clubs/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 2bda3a07..c2fe24f1 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -247,7 +247,7 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: return ClubSummary.parse_raw(resp.text) - async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: + async def delete_club(self, club_id: str, **kwargs) -> Union[ClubSummary, ClubReservation, None]: """ Delete the club with the given id. @@ -258,6 +258,7 @@ async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: reservation_duration_after_suspension_in_hours field. XLE error codes: + 200 - Successfully deleted club with name reservation. 202 - Successfully started suspension process. 204 - Successfully deleted club. 1015 - The requested club is not available. @@ -267,7 +268,8 @@ async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: club_id: Club ID Returns: - :class:`Optional[ClubSummary]`: Club Summary if a suspension process is started, else None + :class:`Union[ClubSummary, ClubReservation, None]`: Club Summary if a suspension process is started, + else Club Reservation if club is a non-hidden club that is successfully deleted, else None. """ url = self.CLUBACCOUNTS_URL + f"/clubs/clubid({club_id})" @@ -276,8 +278,12 @@ async def delete_club(self, club_id: str, **kwargs) -> Optional[ClubSummary]: ) resp.raise_for_status() - if resp.text: + if resp.status_code == 200: + return ClubReservation.parse_raw(resp.text) + elif resp.status_code == 202: return ClubSummary.parse_raw(resp.text) + else: + return None async def suspend_club( self, club_id: str, delete_date: datetime, **kwargs From 25c3099479ba31c3907fb4811d45d40eb7d139b2 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 30 Aug 2023 00:56:20 -0400 Subject: [PATCH 43/44] add documentation for clubroster methods --- xbox/webapi/api/provider/clubs/__init__.py | 94 +++++++++++++++++++--- xbox/webapi/api/provider/clubs/models.py | 10 +-- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index c2fe24f1..8848e3b9 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -658,12 +658,6 @@ async def _update_users_club_roles( """ Add or remove an xuid from a club id. - Affects the following roles: - ClubRole.MEMBER - ClubRole.REQUESTED_TO_JOIN - ClubRole.RECOMMENDED - ClubRole.INVITED - XLE error codes: 200 - Successfully updated user's club role. 1013 - Cannot remove owner from the clubs. @@ -706,6 +700,7 @@ async def _set_users_club_roles( 1008 - Request payload was not understood by the service. 1011 - Requested roles cannot be explicitly modified. 1012 - Cannot modify ban status due to permissions or request format. + 1014 - Following the club has been disabled. 1015 - Cannot modify follow status due to permissions or request format. Args: @@ -736,12 +731,14 @@ async def add_user_to_club( ) -> UpdateRolesResponse: """Add user to the given club. + You can join, ask to join, recommend others to the admins, or invite others through this method. + This can result in the following roles being modified: - ClubRole.FOLLOWER - Given after joining - ClubRole.MEMBER - Given after joining - ClubRole.REQUESTED_TO_JOIN - Given after requesting to join a club - ClubRole.RECOMMENDED - Given after being recommended by a club member - ClubRole.INVITED - Given after being invited by a club member or moderator + ClubRole.FOLLOWER - Caller is Invited or club is OpenJoin + ClubRole.MEMBER - Caller is Invited or club is OpenJoin + ClubRole.REQUESTED_TO_JOIN - Caller is not Invited and club is not OpenJoin + ClubRole.RECOMMENDED - Caller cannot invite but is a club Member + ClubRole.INVITED - Caller can invite to club Args: club_id: Club ID @@ -761,6 +758,9 @@ async def remove_user_from_club( ) -> UpdateRolesResponse: """Remove user from the given club. + You can uninvite or unrecommend users, as well as kick them if you have sufficient permissions. + If no xuid is provided, you leave the club. You cannot leave if you have ClubRole.OWNER. + Args: club_id: Club ID xuid: Xbox user ID. If not provided, defaults to caller xuid @@ -775,11 +775,30 @@ async def remove_user_from_club( ) async def follow_club(self, club_id: str, **kwargs) -> UpdateRolesResponse: + """Follow a club. + + You cannot follow a hidden club if you are not a member. + + Args: + club_id: Club ID + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ return await self._set_users_club_roles( club_id, self.client.xuid, ClubRole.FOLLOWER, True, **kwargs ) async def unfollow_club(self, club_id: str, **kwargs) -> UpdateRolesResponse: + """Unfollow a club. + + Args: + club_id: Club ID + xuid: Xbox user ID + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ return await self._set_users_club_roles( club_id, self.client.xuid, ClubRole.FOLLOWER, False, **kwargs ) @@ -787,6 +806,20 @@ async def unfollow_club(self, club_id: str, **kwargs) -> UpdateRolesResponse: async def ban_user_from_club( self, club_id: str, xuid: str, **kwargs ) -> UpdateRolesResponse: + """Ban user from a club. + + Users with ClubRole.BANNED cannot have any other club role. + + You must have ClubRole.MODERATOR. + You cannot ban another moderator unless you have ClubRole.OWNER. + + Args: + club_id: Club ID + xuid: Xbox user ID + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ return await self._set_users_club_roles( club_id, xuid, ClubRole.BANNED, True, **kwargs ) @@ -794,6 +827,17 @@ async def ban_user_from_club( async def unban_user_from_club( self, club_id: str, xuid: str, **kwargs ) -> UpdateRolesResponse: + """Unban user from a club. + + You must have ClubRole.MODERATOR. + + Args: + club_id: Club ID + xuid: Xbox user ID + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ return await self._set_users_club_roles( club_id, xuid, ClubRole.BANNED, False, **kwargs ) @@ -801,13 +845,39 @@ async def unban_user_from_club( async def add_club_moderator( self, club_id: str, xuid: str, **kwargs ) -> UpdateRolesResponse: + """Give user moderator permissions for the given club. + + You must have ClubRole.OWNER. + + Args: + club_id: Club ID + xuid: Xbox user ID + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ return await self._set_users_club_roles( club_id, xuid, ClubRole.MODERATOR, True, **kwargs ) async def remove_club_moderator( - self, club_id: str, xuid: str, **kwargs + self, club_id: str, xuid: Optional[str] = None, **kwargs ) -> UpdateRolesResponse: + """Remove moderator permissions from a user for the given club. + + If no xuid is provided, you resign as moderator. + + You must have ClubRole.OWNER to remove ClubRole.MODERATOR from other users. + + Args: + club_id: Club ID + xuid: Xbox user ID. If not provided, defaults to caller xuid + + Returns: + :class:`UpdateRolesResponse`: Update Roles Response + """ + xuid = xuid or self.client.xuid + return await self._set_users_club_roles( club_id, xuid, ClubRole.MODERATOR, False, **kwargs ) diff --git a/xbox/webapi/api/provider/clubs/models.py b/xbox/webapi/api/provider/clubs/models.py index c262d42b..aaa6eaf1 100644 --- a/xbox/webapi/api/provider/clubs/models.py +++ b/xbox/webapi/api/provider/clubs/models.py @@ -88,8 +88,8 @@ class ClubSettingsContract(CamelCaseModel): background_image_url: Optional[str] display_image_url: Optional[str] preferred_color: Optional[PreferredColor] - activity_feed_enabled: Optional[bool] # Permanent change - chat_enabled: Optional[bool] # Permanent change + activity_feed_enabled: Optional[bool] # BROKEN -- DO NOT USE + chat_enabled: Optional[bool] # BROKEN -- DO NOT USE lfg_enabled: Optional[bool] # Cannot be modified preferred_locale: Optional[str] request_to_join_enabled: Optional[bool] @@ -105,10 +105,10 @@ class ClubSettingsContract(CamelCaseModel): who_can_join_lfg: Optional[ClubRole] mature_content_enabled: Optional[bool] # Streams marked as mature watch_club_titles_only: Optional[bool] # Streams of club games only - get_recommendation_enabled: Optional[bool] # Permanent change - search_enabled: Optional[bool] # Permanent change + get_recommendation_enabled: Optional[bool] # BROKEN -- DO NOT USE + search_enabled: Optional[bool] # BROKEN -- DO NOT USE delete_enabled: Optional[bool] # Cannot be modified - rename_enabled: Optional[bool] + rename_enabled: Optional[bool] # BROKEN -- DO NOT USE joinability: Optional[ClubJoinability] From da13b55ea2fdf32f0c2dd3252e2494509ad151e4 Mon Sep 17 00:00:00 2001 From: Cubicpath Date: Wed, 30 Aug 2023 00:59:10 -0400 Subject: [PATCH 44/44] black reformat --- xbox/webapi/api/provider/clubs/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xbox/webapi/api/provider/clubs/__init__.py b/xbox/webapi/api/provider/clubs/__init__.py index 8848e3b9..6309ae6f 100644 --- a/xbox/webapi/api/provider/clubs/__init__.py +++ b/xbox/webapi/api/provider/clubs/__init__.py @@ -247,7 +247,9 @@ async def rename_club(self, club_id: str, name: str, **kwargs) -> ClubSummary: return ClubSummary.parse_raw(resp.text) - async def delete_club(self, club_id: str, **kwargs) -> Union[ClubSummary, ClubReservation, None]: + async def delete_club( + self, club_id: str, **kwargs + ) -> Union[ClubSummary, ClubReservation, None]: """ Delete the club with the given id.