From 9a163f0fb10112d2e2a97be67c5e6f1b37d2d879 Mon Sep 17 00:00:00 2001 From: Terbau Date: Fri, 28 May 2021 21:40:47 +0200 Subject: [PATCH 01/15] Add PartyIsFull error --- docs/api.rst | 2 ++ fortnitepy/client.py | 10 +++++++++- fortnitepy/errors.py | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index 8ef15021..8f83e29e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1170,6 +1170,8 @@ Exceptions .. autoexception:: PartyError +.. autoexception:: PartyIsFull + .. autoexception:: Forbidden .. autoexception:: NotFound diff --git a/fortnitepy/client.py b/fortnitepy/client.py index 27fd496a..0a6583ab 100644 --- a/fortnitepy/client.py +++ b/fortnitepy/client.py @@ -37,7 +37,7 @@ from .errors import (PartyError, HTTPException, NotFound, Forbidden, DuplicateFriendship, FriendshipRequestAlreadySent, MaxFriendshipsExceeded, InviteeMaxFriendshipsExceeded, - InviteeMaxFriendshipRequestsExceeded) + InviteeMaxFriendshipRequestsExceeded, PartyIsFull) from .xmpp import XMPPClient from .http import HTTPClient from .user import (ClientUser, User, BlockedUser, SacSearchEntryUser, @@ -3091,6 +3091,8 @@ async def join_party(self, party_id: str) -> ClientParty: You are already a member of this party. NotFound The party was not found. + PartyIsFull + The party you attempted to join is full. Forbidden You are not allowed to join this party because it's private and you have not been a part of it before. @@ -3129,6 +3131,12 @@ async def join_party(self, party_id: str) -> ClientParty: 'You are not allowed to join this party.' ) + m = 'errors.com.epicgames.social.party.party_is_full' + if e.message_code == m: + raise PartyIsFull( + 'The party you attempted to join is full.' + ) + raise try: diff --git a/fortnitepy/errors.py b/fortnitepy/errors.py index 713bd5e7..14ba63b8 100644 --- a/fortnitepy/errors.py +++ b/fortnitepy/errors.py @@ -69,6 +69,10 @@ class PartyError(FortniteException): pass +class PartyIsFull(FortniteException): + """This exception is raised when the bot attempts to join a full party.""" + + class Forbidden(FortniteException): """This exception is raised whenever you attempted a request that your account does not have permission to do. From bf4f283e23afbdaf6bf24d33f06c7113115eafad Mon Sep 17 00:00:00 2001 From: Terbau Date: Fri, 28 May 2021 21:41:22 +0200 Subject: [PATCH 02/15] Add new season timestamps --- fortnitepy/enums.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fortnitepy/enums.py b/fortnitepy/enums.py index 2cb3eee0..8012ad51 100644 --- a/fortnitepy/enums.py +++ b/fortnitepy/enums.py @@ -215,6 +215,7 @@ class SeasonStartTimestamp(Enum): SEASON_13 = 1592352001 SEASON_14 = 1598486401 SEASON_15 = 1606867201 + SEASON_16 = 1615852801 class SeasonEndTimestamp(Enum): @@ -232,6 +233,7 @@ class SeasonEndTimestamp(Enum): SEASON_12 = 1592352000 SEASON_13 = 1598486400 SEASON_14 = 1606867200 + SEASON_15 = 1615852800 class BattlePassStat(Enum): @@ -239,7 +241,8 @@ class BattlePassStat(Enum): SEASON_12 = ('s11_social_bp_level', SeasonEndTimestamp.SEASON_12.value) SEASON_13 = (('s13_social_bp_level', 's11_social_bp_level'), SeasonEndTimestamp.SEASON_13.value) SEASON_14 = ('s14_social_bp_level', SeasonEndTimestamp.SEASON_14.value) - SEASON_15 = ('s15_social_bp_level', None) + SEASON_15 = ('s15_social_bp_level', SeasonEndTimestamp.SEASON_15.value) + SEASON_16 = ('s16_social_bp_level', None) class KairosBackgroundColorPreset(Enum): From 56aeaef8e4334b19e4e1cc0de43fe3e40922f6dc Mon Sep 17 00:00:00 2001 From: Terbau Date: Fri, 28 May 2021 21:51:09 +0200 Subject: [PATCH 03/15] Add friend gift eligibility check --- docs/api.rst | 2 ++ fortnitepy/errors.py | 6 ++++++ fortnitepy/friend.py | 36 ++++++++++++++++++++++++++++++++++++ fortnitepy/http.py | 18 +++++++++++++++++- 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/api.rst b/docs/api.rst index 8f83e29e..18214e13 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1185,3 +1185,5 @@ Exceptions .. autoexception:: InviteeMaxFriendshipsExceeded .. autoexception:: InviteeMaxFriendshipRequestsExceeded + +.. autoexception:: InvalidOffer diff --git a/fortnitepy/errors.py b/fortnitepy/errors.py index 14ba63b8..2bf252a9 100644 --- a/fortnitepy/errors.py +++ b/fortnitepy/errors.py @@ -130,6 +130,12 @@ class InviteeMaxFriendshipRequestsExceeded(FortniteException): pass +class InvalidOffer(FortniteException): + """This exception is raised when an invalid/outdated offer is + passed. Only offers currently in the item shop are valid.""" + pass + + class ValidationFailure(FortniteException): """Represents a validation failure returned. diff --git a/fortnitepy/friend.py b/fortnitepy/friend.py index 805de047..e04587a0 100644 --- a/fortnitepy/friend.py +++ b/fortnitepy/friend.py @@ -457,6 +457,42 @@ async def invite(self) -> None: """ return await self.client.party.invite(self.id) + async def owns_offer(self, offer_id: str) -> bool: + """|coro| + + Checks if a friend owns a currently active offer in the item shop. + + Raises + ------ + InvalidOffer + An invalid/outdated offer_id was passed. Only offers currently in + the item shop are valid. + HTTPException + An error occured while requesting. + + Returns + ------- + :class:`bool` + Whether or not the friend owns the offer. + """ + try: + data = await self.client.http.fortnite_check_gift_eligibility( + self.id, + offer_id, + ) + except HTTPException as exc: + m = 'errors.com.epicgames.modules.gamesubcatalog.purchase_not_allowed' # noqa + if exc.message_code == m: + return True + + m = 'errors.com.epicgames.modules.gamesubcatalog.catalog_out_of_date' # noqa + if exc.message_code == m: + raise InvalidOffer('The offer_id passed is not valid.') + + raise + + return False + class PendingFriendBase(FriendBase): """Represents a pending friend from Fortnite.""" diff --git a/fortnitepy/http.py b/fortnitepy/http.py index edccb214..51c97177 100644 --- a/fortnitepy/http.py +++ b/fortnitepy/http.py @@ -33,7 +33,7 @@ import functools from typing import TYPE_CHECKING, List, Optional, Any, Union, Tuple -from urllib.parse import quote +from urllib.parse import quote as urllibquote from .utils import MaybeLock from .errors import HTTPException @@ -49,6 +49,12 @@ ) +def quote(string: str) -> str: + string = urllibquote(string) + string = string.replace('/', '%2F') + return string + + class HTTPRetryConfig: """Config for how HTTPClient should handle retries. @@ -1209,6 +1215,16 @@ async def fortnite_get_store_catalog(self) -> dict: r = FortnitePublicService('/fortnite/api/storefront/v2/catalog') return await self.get(r) + async def fortnite_check_gift_eligibility(self, + user_id: str, + offer_id: str) -> Any: + r = FortnitePublicService( + '/fortnite/api/storefront/v2/gift/check_eligibility/recipient/{user_id}/offer/{offer_id}', # noqa + user_id=user_id, + offer_id=offer_id, + ) + return await self.get(r) + async def fortnite_get_timeline(self) -> dict: r = FortnitePublicService('/fortnite/api/calendar/v1/timeline') return await self.get(r) From 8fa39de0f82afb4dba6be45d7b7bddf2cec6f6b6 Mon Sep 17 00:00:00 2001 From: Terbau Date: Fri, 28 May 2021 22:02:20 +0200 Subject: [PATCH 04/15] Add party join request support --- docs/api.rst | 25 +++++++++++++++++++ fortnitepy/__init__.py | 2 +- fortnitepy/errors.py | 7 ++++++ fortnitepy/friend.py | 35 +++++++++++++++++++++++++- fortnitepy/http.py | 12 +++++++++ fortnitepy/party.py | 56 ++++++++++++++++++++++++++++++++++++++++++ fortnitepy/xmpp.py | 31 ++++++++++++++++++++++- 7 files changed, 165 insertions(+), 3 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 18214e13..20988087 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -459,10 +459,25 @@ this decorator if you are in a subclass of :class:`Client`. .. warning:: This event is automatically handled by the client which automatically always accepts the user. If you have this event referenced in your code the client won't automatically handle it anymore and you must handle it youself. + + .. note:: + + This event differs from :func:`event_party_join_request` by the fact that this event is fired whenever someone is in the middle of joining the party, while :func:`event_party_join_request` is called when someone explicitly requests to join your private party. :param confirmation: Confirmation object with accessible confirmation methods. :type confirmation: :class:`PartyJoinConfirmation` +.. function:: event_party_join_request(request) + + This event is called when a friend requests to join your private party. + + .. note:: + + This event differs from :func:`event_party_member_confirm` by the fact that this event is called when someone explicitly requests to join the bots party, while :func:`event_party_member_confirm` is an event that is fired whenever someone is in the middle of joining the party. + + :param request: Request object. + :type request: :class:`PartyJoinRequest` + .. function:: event_party_member_chatban(member, reason) This event is called whenever a member of the party has been banned from the party chat. @@ -1026,6 +1041,14 @@ PartyJoinConfirmation .. autoclass:: PartyJoinConfirmation() :members: +PartyJoinRequest +~~~~~~~~~~~~~~~~ + +.. attributetable:: PartyJoinRequest + +.. autoclass:: PartyJoinRequest + :members: + Presence ~~~~~~~~ @@ -1186,4 +1209,6 @@ Exceptions .. autoexception:: InviteeMaxFriendshipRequestsExceeded +.. autoexception:: FriendOffline + .. autoexception:: InvalidOffer diff --git a/fortnitepy/__init__.py b/fortnitepy/__init__.py index 7dfb76a5..4cdee4bb 100644 --- a/fortnitepy/__init__.py +++ b/fortnitepy/__init__.py @@ -36,7 +36,7 @@ from .party import (DefaultPartyConfig, DefaultPartyMemberConfig, PartyMember, ClientPartyMember, JustChattingClientPartyMember, Party, ClientParty, ReceivedPartyInvitation, SentPartyInvitation, - PartyJoinConfirmation) + PartyJoinConfirmation, PartyJoinRequest) from .presence import Presence, PresenceGameplayStats, PresenceParty from .user import (ClientUser, User, BlockedUser, ExternalAuth, UserSearchEntry, SacSearchEntryUser) diff --git a/fortnitepy/errors.py b/fortnitepy/errors.py index 2bf252a9..cbfe2502 100644 --- a/fortnitepy/errors.py +++ b/fortnitepy/errors.py @@ -130,6 +130,13 @@ class InviteeMaxFriendshipRequestsExceeded(FortniteException): pass +class FriendOffline(FortniteException): + """This exception is raised when an action that requires a friend to be + online is performed at an offline friend. + """ + pass + + class InvalidOffer(FortniteException): """This exception is raised when an invalid/outdated offer is passed. Only offers currently in the item shop are valid.""" diff --git a/fortnitepy/friend.py b/fortnitepy/friend.py index e04587a0..9ee8cd13 100644 --- a/fortnitepy/friend.py +++ b/fortnitepy/friend.py @@ -30,7 +30,7 @@ from aioxmpp import JID from .user import UserBase, ExternalAuth -from .errors import PartyError, Forbidden, HTTPException +from .errors import FriendOffline, InvalidOffer, PartyError, Forbidden, HTTPException from .presence import Presence from .enums import Platform @@ -457,6 +457,39 @@ async def invite(self) -> None: """ return await self.client.party.invite(self.id) + async def request_to_join(self) -> None: + """|coro| + + Sends a request to join a friends party. This is mainly used for + requesting to join private parties specifically, but it can be used + for all types of party privacies. + + Raises + ------ + PartyError + You are already a part of this friends party. + FriendOffline + The friend you requested to join is offline. + HTTPException + An error occured while requesting. + """ + try: + await self.client.http.party_send_intention(self.id) + except HTTPException as exc: + m = 'errors.com.epicgames.social.party.user_already_in_party' + if exc.message_code == m: + raise PartyError( + 'The bot is already a part of this friends party.' + ) + + m = 'errors.com.epicgames.social.party.user_has_no_party' + if exc.message_code == m: + raise FriendOffline( + 'The friend you requested to join is offline.' + ) + + raise + async def owns_offer(self, offer_id: str) -> bool: """|coro| diff --git a/fortnitepy/http.py b/fortnitepy/http.py index 51c97177..eafd8e3f 100644 --- a/fortnitepy/http.py +++ b/fortnitepy/http.py @@ -1563,6 +1563,18 @@ async def party_join_request(self, party_id: str) -> Any: ) return await self.post(r, json=payload) + async def party_send_intention(self, user_id: str) -> dict: + payload = { + 'urn:epic:invite:platformdata_s': '', + } + + r = PartyService( + '/party/api/v1/Fortnite/members/{user_id}/intentions/{client_id}', + client_id=self.client.user.id, + user_id=user_id + ) + return await self.post(r, json=payload) + async def party_lookup(self, party_id: str, **kwargs: Any) -> dict: r = PartyService('/party/api/v1/Fortnite/parties/{party_id}', party_id=party_id) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index cbb3926a..97b810ff 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -4045,3 +4045,59 @@ async def reject(self) -> None: return raise + + +class PartyJoinRequest: + """Represents a party join request. These requests are in most cases + only received when the bots party privacy is set to private. + + .. info:: + + There is currently no way to reject a join request. The official + fortnite client does this by simply ignoring the request and waiting + for it to expire. + + Attributes + ---------- + client: :class:`Client` + The client. + party: :class:`ClientParty` + The party the user wants to join. + friend: :class:`Friend` + The friend who requested to join the party. + created_at: :class:`datetime.datetime` + The UTC timestamp of when this join request was created. + expires_at: :class:`datetime.datetime` + The UTC timestamp of when this join request will expire. This + should always be one minute after its creation. + """ + + __slots__ = ('client', 'party', 'friend', 'created_at', 'expires_at') + + def __init__(self, client: 'Client', + party: ClientParty, + friend: User, + data: dict) -> None: + self.client = client + self.party = party + self.friend = friend + self.created_at = self.client.from_iso(data['sent_at']) + self.expires_at = self.client.from_iso(data['expires_at']) + + async def accept(self): + """|coro| + + Accepts a party join request. Accepting this before the request + has expired forces the sender to join the party. If not then the + sender will receive a regular party invite. + + Raises + ------ + PartyError + User is already in your party. + PartyError + The party is full. + HTTPException + An error occured while requesting. + """ + return await self.party.invite(self.friend.id) diff --git a/fortnitepy/xmpp.py b/fortnitepy/xmpp.py index 0cba769a..8e07f3d8 100644 --- a/fortnitepy/xmpp.py +++ b/fortnitepy/xmpp.py @@ -40,7 +40,8 @@ from .errors import XMPPError, PartyError, HTTPException from .message import FriendMessage, PartyMessage -from .party import Party, ReceivedPartyInvitation, PartyJoinConfirmation +from .party import (Party, PartyJoinRequest, ReceivedPartyInvitation, + PartyJoinConfirmation) from .presence import Presence from .enums import AwayStatus @@ -1302,6 +1303,34 @@ async def event_party_member_require_confirmation(self, self.client.dispatch_event('party_member_confirm', confirmation) + @dispatcher.event('com.epicgames.social.party.notification.v0.INITIAL_INTENTION') # noqa + async def event_party_join_request_received(self, ctx: EventContext) -> None: # noqa + body = ctx.body + + user_id = body.get('requester_id') + if user_id != self.client.user.id: + await self.client._join_party_lock.wait() + + party = self.client.party + + if party is None: + return + + if party.id != body.get('party_id'): + return + + friend = self.client.get_friend(user_id) + if friend is None: + return + + request = PartyJoinRequest( + self.client, + party, + friend, + body + ) + self.client.dispatch_event('party_join_request', request) + @dispatcher.event('com.epicgames.social.party.notification.v0.INVITE_DECLINED') # noqa async def event_party_invite_declined(self, ctx: EventContext) -> None: body = ctx.body From 1f62c87fec57ffd3ddf2291a33ae81977ffe415b Mon Sep 17 00:00:00 2001 From: Terbau Date: Fri, 28 May 2021 22:08:05 +0200 Subject: [PATCH 05/15] add docs warning for Friend.request_to_join() --- fortnitepy/friend.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/fortnitepy/friend.py b/fortnitepy/friend.py index 9ee8cd13..f4fca121 100644 --- a/fortnitepy/friend.py +++ b/fortnitepy/friend.py @@ -464,6 +464,13 @@ async def request_to_join(self) -> None: requesting to join private parties specifically, but it can be used for all types of party privacies. + .. warning:: + + If the request is accepted by the receiving friend, the bot will + receive a regular party invitation. Unlike the fortnite client, + fortnitepy will not automatically accept this invitation. You have + to make some logic for doing that yourself. + Raises ------ PartyError From 9196a33616a40deba6a4dd630079b09de125f91c Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:00:24 +0200 Subject: [PATCH 06/15] Reworked RawSquadAssignment --- docs/api.rst | 8 + fortnitepy/__init__.py | 2 +- fortnitepy/client.py | 2 +- fortnitepy/party.py | 322 ++++++++++++++++++++++++++++++++++------- fortnitepy/user.py | 3 + 5 files changed, 281 insertions(+), 56 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 20988087..11e6d7e8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1175,6 +1175,14 @@ Avatar .. autoclass:: Avatar() :members: +SquadAssignment +~~~~~~~~~~~~~~~ + +.. attributetable:: SquadAssignment + +.. autoclass:: SquadAssignment() + :members: + Exceptions ---------- diff --git a/fortnitepy/__init__.py b/fortnitepy/__init__.py index 4cdee4bb..54d19ccc 100644 --- a/fortnitepy/__init__.py +++ b/fortnitepy/__init__.py @@ -36,7 +36,7 @@ from .party import (DefaultPartyConfig, DefaultPartyMemberConfig, PartyMember, ClientPartyMember, JustChattingClientPartyMember, Party, ClientParty, ReceivedPartyInvitation, SentPartyInvitation, - PartyJoinConfirmation, PartyJoinRequest) + PartyJoinConfirmation, PartyJoinRequest, SquadAssignment) from .presence import Presence, PresenceGameplayStats, PresenceParty from .user import (ClientUser, User, BlockedUser, ExternalAuth, UserSearchEntry, SacSearchEntryUser) diff --git a/fortnitepy/client.py b/fortnitepy/client.py index 0a6583ab..342fdd32 100644 --- a/fortnitepy/client.py +++ b/fortnitepy/client.py @@ -2966,7 +2966,7 @@ async def _create_party(self, **default_schema, **updated, **edit_updated, - **party.construct_squad_assignments(), + **party._construct_raw_squad_assignments(), **party.meta.set_voicechat_implementation('EOSVoiceChat') }, deleted=[*deleted, *edit_deleted], diff --git a/fortnitepy/party.py b/fortnitepy/party.py index 97b810ff..a7da9021 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -33,8 +33,9 @@ from typing import (TYPE_CHECKING, Optional, Any, List, Dict, Union, Tuple, Awaitable, Type) -from .enums import Enum +from collections import OrderedDict +from .enums import Enum from .errors import PartyError, Forbidden, HTTPException, NotFound from .user import User from .friend import Friend @@ -46,6 +47,48 @@ from .client import Client +class SquadAssignment: + """Represents a party members squad assignment. A squad assignment + is basically a piece of information about which position a member + has in the party, which is directly related to party teams. + + Parameters + ---------- + position: Optional[:class:`int`] + The position a member should have in the party. If no position + is passed, a position will be automatically given according to + the position priorities set. + hidden: :class:`bool` + Whether or not the member should be hidden in the party. + + .. warning:: + + Being hidden is not a native fortnite feature so be careful + when using this. It might lead to undesirable results. + """ + + __slots__ = ('position', 'hidden') + + def __init__(self, *, + position: Optional[int] = None, + hidden: bool = False) -> None: + self.position = position + self.hidden = hidden + + def __repr__(self): + return (''.format(self)) + + @classmethod + def copy(cls, assignment): + self = cls.__new__(cls) + + self.position = assignment.position + self.hidden = assignment.hidden + + return self + + class DefaultPartyConfig: """Data class for the default party configuration used when a new party is created. @@ -55,24 +98,42 @@ class DefaultPartyConfig: privacy: Optional[:class:`PartyPrivacy`] | The party privacy that should be used. | Defaults to: :attr:`PartyPrivacy.PUBLIC` + max_size: Optional[:class:`int`] + | The maximun party size. Valid party sizes must use a value + between 1 and 16. + | Defaults to ``16`` + chat_enabled: Optional[:class:`bool`] + | Wether or not the party chat should be enabled for the party. + | Defaults to ``True``. team_change_allowed: :class:`bool` | Whether or not players should be able to manually swap party team with another player. This setting only works if the client is the leader of the party. | Defaults to ``True`` - max_size: Optional[:class:`int`] - | The maximun party size. Valid party sizes must use a value - between 1 and 16. - | Defaults to ``16`` + default_squad_assignment: :class:`SquadAssignment` + | The default squad assignment to use for new members. Squad assignments + holds information about a party member's current position and visibility. + Please note that setting a position in the default squad assignment + doesnt actually do anything and it will just be overridden. + | Defaults to ``SquadAssignment(hidden=False)``. + position_priorities: List[int] + | A list of exactly 16 ints all ranging from 0-15. When a new member + joins the party or a member is not defined in a squad assignment + request, it will automatically give the first available position + in this list. + | Defaults to a list of 0-15 in order. + reassign_positions_on_size_change: :class:`bool` + | Whether or not positions should be automatically reassigned if the party + size changes. Set this to ``False`` if you want members to keep their + positions unless manually changed. The reassignment is done according + to the position priorities. + | Defaults to ``True``. joinability: Optional[:class:`PartyJoinability`] | The joinability configuration that should be used. | Defaults to :attr:`PartyJoinability.OPEN` discoverability: Optional[:class:`PartyDiscoverability`] | The discoverability configuration that should be used. | Defaults to :attr:`PartyDiscoverability.ALL` - chat_enabled: Optional[:class:`bool`] - | Wether or not the party chat should be enabled for the party. - | Defaults to ``True``. invite_ttl: Optional[:class:`int`] | How many seconds the invite should be valid for before automatically becoming invalid. @@ -107,6 +168,15 @@ class DefaultPartyConfig: Whether or not players are able to manually swap party team with another player. This setting only works if the client is the leader of the party. + default_squad_assignment: :class:`SquadAssignment` + The default squad assignment to use for new members and members + not specified in manual squad assignments requests. + position_priorities: List[:class:`int`] + A list containing exactly 16 integers ranging from 0-16 with no + duplicates. This is used for position assignments. + reassign_positions_on_size_change: :class:`bool` + Whether or not positions will be automatically reassigned when the + party size changes. cls: Type[:class:`ClientParty`] The default party object used to represent the client's party. """ # noqa @@ -114,11 +184,47 @@ def __init__(self, **kwargs: Any) -> None: self.cls = kwargs.pop('cls', ClientParty) self._client = None self.team_change_allowed = kwargs.pop('team_change_allowed', True) + self.default_squad_assignment = kwargs.pop( + 'default_squad_assignment', + SquadAssignment(hidden=False), + ) + + value = kwargs.pop('position_priorities', None) + if value is None: + self._position_priorities = list(range(16)) + else: + self.position_priorities = value + + self.reassign_positions_on_size_change = kwargs.pop( + 'reassign_positions_on_size_change', + True + ) self.meta = kwargs.pop('meta', []) self._config = {} self.update(kwargs) + @property + def position_priorities(self): + return self._position_priorities + + @position_priorities.setter + def position_priorities(self, value): + def error(): + raise ValueError( + 'position priorities must include exactly 16 integers ' + 'ranging from 0-16.' + ) + + if len(value) != 16: + error() + + for i in range(16): + if i not in value: + error() + + self._position_priorities = value + def _inject_client(self, client: 'Client') -> None: self._client = client @@ -1202,9 +1308,16 @@ def position(self) -> int: | 8-11 = Team 3 | 12-15 = Team 4 """ - for pos_data in self.party.meta.squad_assignments: - if pos_data['memberId'] == self.id: - return pos_data['absoluteMemberIdx'] + member = self.party.get_member(self.id) + return self.party.squad_assignments[member].position + + @property + def hidden(self) -> bool: + """:class:`bool`: Whether or not the member is currently hidden in the + party. A member can only be hidden if a bot is the leader, therefore + this attribute rarely is used.""" + member = self.party.get_member(self.id) + return self.party.squad_assignments[member].hidden @property def platform(self) -> Platform: @@ -2819,6 +2932,7 @@ def __init__(self, client: 'Client', data: dict) -> None: self._id = data.get('id') self._members = {} self._applicants = data.get('applicants', []) + self._squad_assignments = OrderedDict() self._update_invites(data.get('invites', [])) self._update_config(data.get('config')) @@ -2901,6 +3015,13 @@ def privacy(self) -> PartyPrivacy: """:class:`PartyPrivacy`: The currently set privacy of this party.""" return self.meta.privacy + @property + def squad_assignments(self) -> Dict[PartyMember, SquadAssignment]: + """Dict[:class:`PartyMember`, :class:`SquadAssignment`]: The squad assignments + for this party. This includes information about a members position and + visibility.""" + return self._squad_assignments + def _add_member(self, member: PartyMember) -> None: self._members[member.id] = member @@ -2921,6 +3042,18 @@ def get_member(self, user_id: str) -> Optional[PartyMember]: """ return self._members.get(user_id) + def _update_squad_assignments(self, raw): + results = OrderedDict() + for data in sorted(raw, key=lambda o: o['absoluteMemberIdx']): + member = self.get_member(data['memberId']) + if member is None: + continue + + assignment = SquadAssignment(position=data['absoluteMemberIdx']) + results[member] = assignment + + self._squad_assignments = results + def _update(self, data: dict) -> None: try: config = data['config'] @@ -2935,6 +3068,13 @@ def _update(self, data: dict) -> None: self._update_config({**self.config, **config}) + _update_squad_assignments = False + key = 'Default:RawSquadAssignments_j' + _assignments = data['party_state_updated'].get(key) + if _assignments: + if _assignments != self.meta.schema.get(key, ''): + _update_squad_assignments = True + self.meta.update(data['party_state_updated'], raw=True) self.meta.remove(data['party_state_removed']) @@ -3424,70 +3564,144 @@ async def edit_and_keep_party(): await super().edit_and_keep(*coros) def construct_squad_assignments(self, - new_positions: Dict[str, int] = {} - ) -> Dict[str, Any]: - existing = self.meta.squad_assignments - existing_ids = [d['memberId'] for d in existing] - taken_pos = set(new_positions.values()) - to_assign = [] + assignments: Optional[Dict[PartyMember, SquadAssignment]] = None, # noqa + new_positions: Optional[Dict[str, int]] = None # noqa + ) -> Dict[PartyMember, SquadAssignment]: + existing = self._squad_assignments + + results = {} + already_assigned = set() + + positions = self._default_config.position_priorities.copy() + reassign = self._default_config.reassign_positions_on_size_change + default_assignment = self._default_config.default_squad_assignment + + def assign(member, assignment=None, position=True): + if assignment is None: + assignment = SquadAssignment.copy(default_assignment) + position = True + + if str(position) not in ('True', 'False'): + assignment.position = position + positions.remove(position) + elif position: + assignment.position = positions.pop(0) + else: + try: + positions.remove(assignment.position) + except ValueError: + pass - for member in self._members.values(): - if member.id not in existing_ids: - to_assign.append(member) + results[member] = assignment + already_assigned.add(member.id) - new = [] - for user_id, pos in new_positions.items(): - new.append({ - 'memberId': user_id, - 'absoluteMemberIdx': pos - }) - - i = 0 + if new_positions is not None: + for user_id, position in new_positions.items(): + member = self.get_member(user_id) + if member is None: + continue - def increment(): - nonlocal i + assignment = existing.get(member) + assign(member, assignment, position=position) - i += 1 - while i in taken_pos: - i += 1 + if assignments is not None: + for m, assignment in assignments.items(): + if assignment.position is not None: + try: + positions.remove(assignment.position) + except ValueError: + raise ValueError('Duplicate positions set.') + else: + assign(m, assignment, position=False) + else: + assign(m, assignment) - for member_data in existing: - user_id = member_data['memberId'] - if user_id not in self._members: + for member in self._members.values(): + if member.id in already_assigned: continue - if user_id in new_positions: - continue + assignment = existing.get(member) + should_reassign = reassign + if assignment and assignment.position not in positions: + should_reassign = True - new.append({ - 'memberId': user_id, - 'absoluteMemberIdx': i - }) - increment() + assign(member, assignment, position=should_reassign) - assignments = list(sorted(new, key=lambda o: o['absoluteMemberIdx'])) - if assignments: - last_pos = assignments[-1]['absoluteMemberIdx'] + 1 - else: - last_pos = 0 + results = OrderedDict( + sorted(results.items(), key=lambda o: o[1].position) + ) + + self._squad_assignments = results + return results - for i, member in enumerate(to_assign, last_pos): - assignments.append({ + def _convert_squad_assignments(self, assignments): + results = [] + for member, assignment in assignments.items(): + if assignment.hidden: + continue + + results.append({ 'memberId': member.id, - 'absoluteMemberIdx': i + 'absoluteMemberIdx': assignment.position, }) - return self.meta.set_squad_assignments(assignments) + return results + + def _construct_raw_squad_assignments(self, + assignments: Dict[PartyMember, SquadAssignment] = None, # noqa + new_positions: Dict[str, int] = None, + could_be_edit: bool = False + ) -> Dict[str, Any]: + ret = self.construct_squad_assignments( + assignments=assignments, + new_positions=new_positions, + could_be_edit=could_be_edit, + ) + raw = self._convert_squad_assignments(ret) + prop = self.meta.set_squad_assignments(raw) + return prop async def refresh_squad_assignments(self, - new_positions: Dict[str, int] = {}, + assignments: Dict[PartyMember, SquadAssignment] = None, # noqa + new_positions: Dict[str, int] = None, could_be_edit: bool = False) -> None: - prop = self.construct_squad_assignments(new_positions=new_positions) + prop = self._construct_raw_squad_assignments( + assignments=assignments, + new_positions=new_positions, + could_be_edit=could_be_edit, + ) check = not self.edit_lock.locked() if could_be_edit else True if check: return await self.patch(updated=prop) + async def set_squad_assignments(self, assignments: Dict[PartyMember, SquadAssignment]) -> None: # noqa + """|coro| + + Sets squad assignments for members of the party. + + Parameters + ---------- + assignments: Dict[:class:`PartyMember`, :class:`SquadAssignment`] + Pre-defined assignments to set. If a member is missing from this + dict, they will be automatically added to the final request. + + Example: :: + + { + member1 = fortnitepy.SquadAssignment(position=5), + member2 = fortnitepy.SquadAssignment(hidden=True) + } + + Raises + ------ + ValueError + Duplicate positions were set in the assignments. + HTTPException + An error occured while requesting. + """ + return await self.refresh_squad_assignments(assignments=assignments) + async def _invite(self, friend: Friend) -> None: if friend.id in self._members: raise PartyError('User is already in you party.') diff --git a/fortnitepy/user.py b/fortnitepy/user.py index 0860131b..aead3d0a 100644 --- a/fortnitepy/user.py +++ b/fortnitepy/user.py @@ -113,6 +113,9 @@ def __init__(self, client: 'Client', data: dict, **kwargs: Any) -> None: if data: self._update(data) + def __hash__(self) -> int: + return hash(self._id) + def __str__(self) -> str: return self.display_name From 8b09ddde773ba067e7dff070ae8a11f380084393 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:05:00 +0200 Subject: [PATCH 07/15] party member team swap request changes --- docs/api.rst | 8 +++--- fortnitepy/party.py | 59 +++++++++++++++++++++++++++++++++++++++------ fortnitepy/xmpp.py | 42 ++++++++++++++++++++------------ 3 files changed, 81 insertions(+), 28 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 11e6d7e8..acf06627 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -528,18 +528,18 @@ this decorator if you are in a subclass of :class:`Client`. :param after: The current party privacy. :type after: :class:`Privacy` -.. function:: event_party_team_swap(member, other) +.. function:: event_party_member_team_swap(member, other) .. note:: Because of how party teams work, you can swap team with another member without their permission. If you don't want this to be possible, you can set ``team_change_allowed`` to ``False`` in :class:`DefaultPartyConfig`. - This event is called whenever a party member swaps party team with another member. You can get their new positions from :attr:`PartyMember.position`. + This event is called whenever a party member swaps their position. If the member switches to a position that was taken my another member, the two members will swap positions. You can get their new positions from :attr:`PartyMember.position`. :param member: The member that instigated the team swap. :type member: :class:`PartyMember` - :param other: The member that was swapped teams with. - :type other: :class:`PartyMember` + :param other: The member that was swapped teams with. If no member was previously holding the position, this will be ``None``. + :type other: Optional[:class:`PartyMember`] .. function:: event_party_member_ready_change(member, before, after) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index a7da9021..f6991249 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -870,12 +870,13 @@ def set_frontend_marker(self, *, def set_member_squad_assignment_request(self, current_pos: int, target_pos: int, - target_id: str, - version: int) -> Dict[str, Any]: + version: int, + target_id: Optional[str] = None + ) -> Dict[str, Any]: data = { 'startingAbsoluteIdx': current_pos, 'targetAbsoluteIdx': target_pos, - 'swapTargetMemberId': target_id, + 'swapTargetMemberId': target_id or 'INVALID', 'version': version, } final = {'MemberSquadAssignmentRequest': data} @@ -1893,7 +1894,7 @@ async def chatban(self, reason: Optional[str] = None) -> None: async def swap_position(self) -> None: """|coro| - Swaps the clients team position with this member. + Swaps the clients party position with this member. Raises ------ @@ -1901,12 +1902,12 @@ async def swap_position(self) -> None: An error occured while requesting. """ me = self.party.me - me._assignment_version += 1 - prop = self.meta.set_member_squad_assignment_request( + version = me._assignment_version + 1 + prop = me.meta.set_member_squad_assignment_request( me.position, self.position, - self.id, - me._assignment_version + version, + target_id=self.id, ) if not me.edit_lock.locked(): @@ -2776,6 +2777,48 @@ async def clear_assisted_challenge(self) -> None: """ await self.set_assisted_challenge(quest="") + async def set_position(self, position: int) -> None: + """|coro| + + The the clients party position. + + Parameters + ---------- + position: :class:`int` + An integer ranging from 0-15. If a position is already held by + someone else, then the client and the existing holder will swap + positions. + + Raises + ------ + ValueError + The passed position is out of bounds. + HTTPException + An error occured while requesting. + """ + if position < 0 or position > 15: + raise ValueError('The passed position is out of bounds.') + + target_id = None + for member, assignment in self.party.squad_assignments.items(): + if assignment.position == position: + if member.id == self.id: + return + + target_id = member.id + break + + version = self._assignment_version + 1 + prop = self.meta.set_member_squad_assignment_request( + self.position, + position, + version, + target_id=target_id, + ) + + if not self.edit_lock.locked(): + return await self.patch(updated=prop) + async def set_in_match(self, *, players_left: int = 100, started_at: datetime.timedelta = None) -> None: """|coro| diff --git a/fortnitepy/xmpp.py b/fortnitepy/xmpp.py index 8e07f3d8..69f522ec 100644 --- a/fortnitepy/xmpp.py +++ b/fortnitepy/xmpp.py @@ -1218,27 +1218,37 @@ def _getattr(member, key): if req_j is not None: req = json.loads(req_j)['MemberSquadAssignmentRequest'] version = req.get('version') - if version is not None and version != member._assignment_version: # noqa + + if member.id == self.client.user.id: + assignment_version = party.me._assignment_version + else: + assignment_version = member._assignment_version + + if version is not None and version != assignment_version: + new_positions = { + member.id: req['targetAbsoluteIdx'], + } + member._assignment_version = version + if member.id == self.client.user.id: + party.me._assignment_version = version swap_member_id = req['swapTargetMemberId'] if swap_member_id != 'INVALID': - new_positions = { - member.id: req['targetAbsoluteIdx'], - swap_member_id: req['startingAbsoluteIdx'] - } - if party.me.leader: - await party.refresh_squad_assignments( - new_positions=new_positions - ) + new_positions[swap_member_id] = req['startingAbsoluteIdx'] # noqa - try: - self.client.dispatch_event( - 'party_member_team_swap', - *[party._members[k] for k in new_positions] - ) - except KeyError: - pass + if party.me.leader: + await party.refresh_squad_assignments( + new_positions=new_positions + ) + + try: + self.client.dispatch_event( + 'party_member_team_swap', + *[party._members.get(k) for k in (member.id, swap_member_id)] # noqa + ) + except KeyError: + pass self.client.dispatch_event('party_member_update', member) From 3a85dfb4ad3510ece1f11a3616a48f6b35d97941 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:05:51 +0200 Subject: [PATCH 08/15] wait for member meta on party join --- fortnitepy/client.py | 6 ++++++ fortnitepy/party.py | 1 + fortnitepy/xmpp.py | 47 +++++++++++++++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/fortnitepy/client.py b/fortnitepy/client.py index 342fdd32..81907236 100644 --- a/fortnitepy/client.py +++ b/fortnitepy/client.py @@ -535,6 +535,11 @@ class Client: or simply is ``None`` on objects deriving from :class:`User`. Keep in mind that :attr:`User.id` always will be available. You can use :meth:`User.fetch()` to update all missing attributes. + wait_for_member_meta_in_events: :class:`bool` + Whether or not the client should wait for party member meta (information + about outfit, backpack etc.) before dispatching events like + :func:`event_party_member_join()`. If this is disabled then member objects + in the events won't have the correct meta. Defaults to ``True``. Attributes ---------- @@ -568,6 +573,7 @@ def __init__(self, auth, *, self.service_port = kwargs.get('xmpp_port', 5222) self.cache_users = kwargs.get('cache_users', True) self.fetch_user_data_in_events = kwargs.get('fetch_user_data_in_events', True) # noqa + self.wait_for_member_meta_in_events = kwargs.get('wait_for_member_meta_in_events', True) # noqa self.kill_other_sessions = True self.accept_eula = True diff --git a/fortnitepy/party.py b/fortnitepy/party.py index f6991249..99d7e576 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -570,6 +570,7 @@ def __init__(self, member: 'PartyMemberBase', self.member = member self.meta_ready_event = asyncio.Event() + self.has_been_updated = True self.def_character = DefaultCharactersChapter2.get_random_name() self.schema = { diff --git a/fortnitepy/xmpp.py b/fortnitepy/xmpp.py index 69f522ec..94527331 100644 --- a/fortnitepy/xmpp.py +++ b/fortnitepy/xmpp.py @@ -901,6 +901,8 @@ async def event_party_member_joined(self, fetch_user_data=self.client.fetch_user_data_in_events, ))[0] + member.meta.has_been_updated = False + fut = None if party.me is not None: party.me.do_on_member_join_patch() @@ -925,6 +927,19 @@ def check(m): if fut is not None: await fut + self.client.dispatch_event('internal_party_member_join', member) + + if self.client.wait_for_member_meta_in_events: + if not member.meta.has_been_updated: + try: + await self.client.wait_for( + 'internal_initial_party_member_meta', + check=lambda m: m.id == member.id, + timeout=2 + ) + except asyncio.TimeoutError: + pass + self.client.dispatch_event('party_member_join', member) @dispatcher.event('com.epicgames.social.party.notification.v0.MEMBER_LEFT') @@ -1169,7 +1184,7 @@ def check(m): try: member = await self.client.wait_for( - 'party_member_join', + 'internal_party_member_join', check=check, timeout=1 ) @@ -1200,16 +1215,25 @@ def _getattr(member, key): value = value() return value - _check = ('ready', 'input', 'assisted_challenge', 'outfit', 'backpack', - 'pet', 'pickaxe', 'contrail', 'emote', 'emoji', 'banner', - 'battlepass_info', 'in_match', 'match_players_left', - 'enlightenments', 'corruption', 'outfit_variants', - 'backpack_variants', 'pickaxe_variants', - 'contrail_variants', 'lobby_map_marker_is_visible', - 'lobby_map_marker_coordinates',) - pre_values = {k: _getattr(member, k) for k in _check} + should_dispatch_extra_events = member.meta.has_been_updated + if should_dispatch_extra_events: + _check = ('ready', 'input', 'assisted_challenge', 'outfit', + 'backpack', 'pet', 'pickaxe', 'contrail', 'emote', + 'emoji', 'banner', 'battlepass_info', 'in_match', + 'match_players_left', 'enlightenments', 'corruption', + 'outfit_variants', 'backpack_variants', + 'pickaxe_variants', 'contrail_variants', + 'lobby_map_marker_is_visible', + 'lobby_map_marker_coordinates',) + pre_values = {k: _getattr(member, k) for k in _check} member.update(body) + if len(body['member_state_updated']) > 5 and not member.meta.has_been_updated: # noqa + member.meta.has_been_updated = True + self.client.dispatch_event( + 'internal_initial_party_member_meta', + member + ) if party._default_config.team_change_allowed or not party.me.leader: req_j = body['member_state_updated'].get( @@ -1252,6 +1276,11 @@ def _getattr(member, key): self.client.dispatch_event('party_member_update', member) + # Only dispatch the events below if the update is not the initial + # party join one. + if not should_dispatch_extra_events: + return + def _dispatch(key, member, pre_value, value): self.client.dispatch_event( 'party_member_{0}_change'.format(key), From aa609ddfe801e8a61071cbf262e6f4ab57d2f332 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:06:44 +0200 Subject: [PATCH 09/15] Fix ClientPartyMember role reset --- fortnitepy/party.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index 99d7e576..208aa0ed 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -3421,6 +3421,8 @@ def _update_roles(self, new_leader): if new_leader.id == self.client.user.id: self.client.party.me.update_role('CAPTAIN') + else: + self.client.party.me.update_role(None) async def _update_members(self, members: Optional[list] = None, remove_missing: bool = True, From 28d7d311f4f3f3e305471cc63272a70fe51dde96 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:07:23 +0200 Subject: [PATCH 10/15] Fix rare party leader promote race condition --- fortnitepy/party.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index 208aa0ed..dfae746d 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -3139,15 +3139,27 @@ def _update(self, data: dict) -> None: if found: self.config['privacy'] = found - captain_id = data.get('captain_id') - if captain_id is not None: - leader = self.leader - if leader is not None and captain_id != leader.id: - delt = datetime.datetime.utcnow() - leader._role_updated_at - if delt.total_seconds() > 3: - member = self.get_member(captain_id) - if member is not None: - self._update_roles(member) + # Only update role if the client is not in the party. This is because + # we don't want the role being potentially updated before + # MEMBER_NEW_CAPTAIN is received which could cause the promote + # event to pass two of the same member objects. This piece of code + # is essentially just here to update roles of parties that the client + # doesn't receive events for. + if self.client.user.id not in self._members: + captain_id = data.get('captain_id') + if captain_id is not None: + leader = self.leader + if leader is not None and captain_id != leader.id: + delt = datetime.datetime.utcnow() - leader._role_updated_at + if delt.total_seconds() > 3: + member = self.get_member(captain_id) + if member is not None: + self._update_roles(member) + + if _update_squad_assignments: + if self.leader.id != self.client.user.id: + _assignments = json.loads(_assignments)['RawSquadAssignments'] + self._update_squad_assignments(_assignments) def _update_roles(self, new_leader): for member in self._members.values(): From bdd3807c9bbaa14bf01b29063d2124c8a81c9ab4 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:07:55 +0200 Subject: [PATCH 11/15] Handle slow closing connections --- fortnitepy/client.py | 1 + fortnitepy/http.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/fortnitepy/client.py b/fortnitepy/client.py index 81907236..3318df25 100644 --- a/fortnitepy/client.py +++ b/fortnitepy/client.py @@ -450,6 +450,7 @@ async def runner(): except KeyboardInterrupt: if not _stopped: + _stopped = True loop.run_until_complete(close_multiple(clients)) finally: future.remove_done_callback(close) diff --git a/fortnitepy/http.py b/fortnitepy/http.py index eafd8e3f..560bed19 100644 --- a/fortnitepy/http.py +++ b/fortnitepy/http.py @@ -439,7 +439,10 @@ async def close(self) -> None: if self.__session: event = create_aiohttp_closed_event(self.__session) await self.__session.close() - await event.wait() + try: + await asyncio.wait_for(event.wait(), timeout=2) + except asyncio.TimeoutError: + pass def create_connection(self) -> None: self.__session = aiohttp.ClientSession( From 9b2e0e95f9515a3935a543aee21d077da129a01d Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 16:08:12 +0200 Subject: [PATCH 12/15] Minor misc changes --- fortnitepy/errors.py | 5 ++++- fortnitepy/friend.py | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fortnitepy/errors.py b/fortnitepy/errors.py index cbfe2502..aebfcd19 100644 --- a/fortnitepy/errors.py +++ b/fortnitepy/errors.py @@ -25,7 +25,10 @@ """ from aiohttp import ClientResponse -from typing import Union +from typing import Union, TYPE_CHECKING + +if TYPE_CHECKING: + from .http import Route # noqa class FortniteException(Exception): diff --git a/fortnitepy/friend.py b/fortnitepy/friend.py index f4fca121..a6fde4af 100644 --- a/fortnitepy/friend.py +++ b/fortnitepy/friend.py @@ -27,10 +27,10 @@ import datetime from typing import TYPE_CHECKING, List, Optional -from aioxmpp import JID -from .user import UserBase, ExternalAuth -from .errors import FriendOffline, InvalidOffer, PartyError, Forbidden, HTTPException +from .user import UserBase +from .errors import (FriendOffline, InvalidOffer, PartyError, Forbidden, + HTTPException) from .presence import Presence from .enums import Platform @@ -516,7 +516,7 @@ async def owns_offer(self, offer_id: str) -> bool: Whether or not the friend owns the offer. """ try: - data = await self.client.http.fortnite_check_gift_eligibility( + await self.client.http.fortnite_check_gift_eligibility( self.id, offer_id, ) From aa2ca34833a839b3907b08b8ee65728e693def8f Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 19:12:03 +0200 Subject: [PATCH 13/15] add Forbidden to ClientParty.set_squad_assignments() --- fortnitepy/party.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index dfae746d..e1922039 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -3755,9 +3755,14 @@ async def set_squad_assignments(self, assignments: Dict[PartyMember, SquadAssign ------ ValueError Duplicate positions were set in the assignments. + Forbidden + You are not the leader of the party. HTTPException An error occured while requesting. """ + if self.me is not None and not self.me.leader: + raise Forbidden('You have to be leader for this action to work.') + return await self.refresh_squad_assignments(assignments=assignments) async def _invite(self, friend: Friend) -> None: From b47b2415b2843fdc23f4b19515336138f2b31777 Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 19:12:21 +0200 Subject: [PATCH 14/15] small docs fix --- fortnitepy/party.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fortnitepy/party.py b/fortnitepy/party.py index e1922039..22897c72 100644 --- a/fortnitepy/party.py +++ b/fortnitepy/party.py @@ -3747,8 +3747,8 @@ async def set_squad_assignments(self, assignments: Dict[PartyMember, SquadAssign Example: :: { - member1 = fortnitepy.SquadAssignment(position=5), - member2 = fortnitepy.SquadAssignment(hidden=True) + member1: fortnitepy.SquadAssignment(position=5), + member2: fortnitepy.SquadAssignment(hidden=True) } Raises From 6b8c3d23a3922ba222ec4fff8ae9565a6ea90d2f Mon Sep 17 00:00:00 2001 From: Terbau Date: Mon, 31 May 2021 19:12:49 +0200 Subject: [PATCH 15/15] v3.6.0 changelog + version bump --- docs/changelog.rst | 43 ++++++++++++++++++++++++++++++++++++++++++ fortnitepy/__init__.py | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index e3f40d09..29bf0df1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,49 @@ Changelog Detailed version changes. +v3.6.0 +------ + +Changes +~~~~~~~ + +- (**Breaking**) Party member meta change events like :func:`event_party_member_outfit_change()` are no longer emitted by the initial meta update that is received when a member joins the party. +- (**Breaking**) The "other" argument in :func:`event_party_member_team_swap()` can now be ``None`` if the member swapped to an empty position. + +Added +~~~~~ + +- (**Breaking**) Added :exc:`PartyIsFull` exception which is now raised by :class:`Client.join_party()`. +- Added functionality related to the new "Request to join" feature. + - Added :meth:`Friend.request_to_join()`. Read the warning field in the docs before using it. + - Added :func:`event_party_join_request()`. +- Completely reworked party squad assignments and added functionality related to it. + - Added the following kwargs to :class:`DefaultPartyConfig`: + - ``default_squad_assignment`` + - ``position_priorities`` + - ``reassign_positions_on_size_change`` + - Added :meth:`ClientPartyMember.set_position()`. + - Added :meth:`ClientParty.set_squad_assignments()`. This can be used to "hide" certain members and also change their positions. + - Added :attr:`ClientParty.squad_assignments`. + - Added :attr:`PartyMember.hidden`. +- Added kwarg ``wait_for_member_meta_in_events`` to :class:`Client`. It is ``True`` by default which introduces a ~1 sec delay to :func:`event_party_member_join()`. +- Added :meth:`Friend.owns_offer()` which can be used to check if a friend owns an offer currently in the item-shop. +- Added timestamps for season 16. + +Bug Fixes +~~~~~~~~~ + +- Fixed an issue that caused the client to think that it was party leader after promoting someone else. +- Fixed a rare race condition that could cause :func:`event_party_member_promote()` to pass the new leaders object as the old one. +- Fixed some applications not closing smoothly due to another bug fix. +- Fixed a relatively hidden bug in :meth:`PartyMember.swap_position()`. + +Misc +~~~~ + +- Fixed :func:`event_party_member_team_swap()` having the incorrect name specified in the docs. + + v3.5.0 ------ diff --git a/fortnitepy/__init__.py b/fortnitepy/__init__.py index 54d19ccc..656499cd 100644 --- a/fortnitepy/__init__.py +++ b/fortnitepy/__init__.py @@ -25,7 +25,7 @@ SOFTWARE. """ -__version__ = '3.5.0' +__version__ = '3.6.0' from .client import Client, run_multiple, start_multiple, close_multiple from .auth import (Auth, EmailAndPasswordAuth, ExchangeCodeAuth,