From d0e14b74e17a406aa25bc37e0eddf298e62dccc8 Mon Sep 17 00:00:00 2001 From: Askaholic Date: Sun, 14 May 2023 22:41:42 -0800 Subject: [PATCH] Add TypedDict definitions for all client messages --- Pipfile | 1 + Pipfile.lock | 4 +- server/lobbyconnection.py | 77 ++++++--- server/protocol/types/__init__.py | 0 server/protocol/types/client.py | 271 ++++++++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 server/protocol/types/__init__.py create mode 100644 server/protocol/types/client.py diff --git a/Pipfile b/Pipfile index cb28b0846..00dba0a90 100644 --- a/Pipfile +++ b/Pipfile @@ -28,6 +28,7 @@ sqlalchemy = ">=2.0.0" trueskill = "*" twilio = ">=7.0.0" uvloop = {version = "*", markers = "sys_platform != 'win32'"} +typing-extensions = "*" [dev-packages] hypothesis = "*" # Versions between 6.47.1 and 6.56.4 added a prerelease dependency. See https://github.com/pypa/pipenv/issues/1760 diff --git a/Pipfile.lock b/Pipfile.lock index c51fa3d92..df3f3070c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "816ffa2da529a42d808057d7505f783ebc0d3e5118df782527cd6e0611d3ae73" + "sha256": "0f3f8d3f04022df7329a85e5b4f15ab1690ee2d02b8e3e12c66f81e126524076" }, "pipfile-spec": 6, "requires": { @@ -825,7 +825,7 @@ "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4" ], - "markers": "python_version >= '3.7'", + "index": "pypi", "version": "==4.5.0" }, "tzlocal": { diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py index daac46045..5f9f301f5 100644 --- a/server/lobbyconnection.py +++ b/server/lobbyconnection.py @@ -56,6 +56,7 @@ from .player_service import PlayerService from .players import Player, PlayerState from .protocol import DisconnectedError, Protocol +from .protocol.types import client from .rating import InclusiveRange, RatingType from .rating_service import RatingService from .types import Address, GameLaunchOptions @@ -203,16 +204,16 @@ async def on_message_received(self, message): self._logger.exception(ex) await self.abort("Error processing command") - async def command_ping(self, msg): + async def command_ping(self, msg: client.Ping): await self.send({"command": "pong"}) - async def command_pong(self, msg): + async def command_pong(self, msg: client.Pong): pass - async def command_create_account(self, message): + async def command_create_account(self, message: dict): raise ClientError("FAF no longer supports direct registration. Please use the website to register.", recoverable=True) - async def command_coop_list(self, message): + async def command_coop_list(self, message: client.CoopList): """Request for coop map list""" async with self._db.acquire() as conn: result = await conn.stream(select(coop_map)) @@ -240,10 +241,13 @@ async def command_coop_list(self, message): "featured_mod": "coop" }) - async def command_matchmaker_info(self, message): + async def command_matchmaker_info(self, message: client.MatchmakerInfo): await self.send({ "command": "matchmaker_info", - "queues": [queue.to_dict() for queue in self.ladder_service.queues.values()] + "queues": [ + queue.to_dict() + for queue in self.ladder_service.queues.values() + ] }) async def send_game_list(self): @@ -255,7 +259,7 @@ async def send_game_list(self): ] }) - async def command_social_remove(self, message): + async def command_social_remove(self, message: client.SocialRemove): if "friend" in message: subject_id = message["friend"] player_attr = self.player.friends @@ -275,7 +279,7 @@ async def command_social_remove(self, message): with contextlib.suppress(KeyError): player_attr.remove(subject_id) - async def command_social_add(self, message): + async def command_social_add(self, message: client.SocialAdd): if "friend" in message: status = "FRIEND" subject_id = message["friend"] @@ -309,7 +313,7 @@ async def send_updated_achievements(self, updated_achievements): "updated_achievements": updated_achievements }) - async def command_admin(self, message): + async def command_admin(self, message: client.Admin): action = message["action"] if action == "closeFA": @@ -492,7 +496,7 @@ async def check_policy_conformity(self, player_id, uid_hash, session, ignore_res return response.get("result", "") == "honest" - async def command_auth(self, message): + async def command_auth(self, message: client.Auth): token = message["token"] unique_id = message["unique_id"] player_id = await self.oauth_service.get_player_id_from_token(token) @@ -521,7 +525,7 @@ async def command_auth(self, message): player_id, username, new_irc_password, unique_id, auth_method ) - async def command_hello(self, message): + async def command_hello(self, message: client.Hello): login = message["login"].strip() password = message["password"] unique_id = message["unique_id"] @@ -695,21 +699,29 @@ async def update_irc_password(self, conn, login, password): except (OperationalError, ProgrammingError): self._logger.error("Failure updating NickServ password for %s", login) - async def command_restore_game_session(self, message): + async def command_restore_game_session( + self, + message: client.RestoreGameSession + ): assert self.player is not None - game_id = int(message.get("game_id")) + game_id = int(message["game_id"]) # Restore the player's game connection, if the game still exists and is live if not game_id or game_id not in self.game_service: - await self.send_warning("The game you were connected to does no longer exist") + await self.send_warning("The game you were connected to no longer exists") return - game = self.game_service[game_id] # type: Game + game: Game = self.game_service[game_id] if game.state is not GameState.LOBBY and game.state is not GameState.LIVE: await self.send_warning("The game you were connected to is no longer available") return + if self.player.id not in game._players_at_launch: + await self.send_warning("You are not part of this game") + # TODO: Implement me + return + self._logger.debug("Restoring game session of player %s to game %s", self.player, game) self.game_connection = GameConnection( database=self._db, @@ -725,14 +737,14 @@ async def command_restore_game_session(self, message): self.player.state = PlayerState.PLAYING self.player.game = game - async def command_ask_session(self, message): + async def command_ask_session(self, message: client.AskSession): user_agent = message.get("user_agent") version = message.get("version") self._set_user_agent_and_version(user_agent, version) await self._check_user_agent() await self.send({"command": "session", "session": self.session}) - async def command_avatar(self, message): + async def command_avatar(self, message: client.Avatar): action = message["action"] if action == "list_avatar": @@ -839,7 +851,7 @@ async def wrapper(self, message): @ice_only @player_idle("join a game") - async def command_game_join(self, message): + async def command_game_join(self, message: client.GameJoin): """ We are going to join a game. """ @@ -887,7 +899,7 @@ async def command_game_join(self, message): await self.launch_game(game, is_host=False) @ice_only - async def command_game_matchmaking(self, message): + async def command_game_matchmaking(self, message: client.GameMatchmaking): queue_name = str( message.get("queue_name") or message.get("mod", "ladder1v1") ) @@ -940,7 +952,7 @@ async def command_game_matchmaking(self, message): @ice_only @player_idle("host a game") - async def command_game_host(self, message): + async def command_game_host(self, message: client.GameHost): assert isinstance(self.player, Player) await self.abort_connection_if_banned() @@ -978,7 +990,7 @@ async def command_game_host(self, message): ) await self.launch_game(game, is_host=True) - async def command_match_ready(self, message): + async def command_match_ready(self, message: client.MatchReady): """ Replace with full implementation when implemented in client, see: https://github.com/FAForever/downlords-faf-client/issues/1783 @@ -1063,7 +1075,7 @@ def _prepare_launch_game( return {k: v for k, v in cmd.items() if v is not None} - async def command_modvault(self, message): + async def command_modvault(self, message: client.ModVault): type = message["type"] async with self._db.acquire() as conn: @@ -1137,7 +1149,7 @@ async def command_modvault(self, message): else: raise ValueError("invalid type argument") - async def command_ice_servers(self, message): + async def command_ice_servers(self, message: client.IceServers): if not self.player: return @@ -1157,7 +1169,7 @@ async def command_ice_servers(self, message): }) @player_idle("invite a player") - async def command_invite_to_party(self, message): + async def command_invite_to_party(self, message: client.InviteToParty): recipient = self.player_service.get_player(message["recipient_id"]) if recipient is None: # TODO: Client localized message @@ -1169,7 +1181,10 @@ async def command_invite_to_party(self, message): self.party_service.invite_player_to_party(self.player, recipient) @player_idle("join a party") - async def command_accept_party_invite(self, message): + async def command_accept_party_invite( + self, + message: client.AcceptPartyInvite + ): sender = self.player_service.get_player(message["sender_id"]) if sender is None: # TODO: Client localized message @@ -1178,7 +1193,10 @@ async def command_accept_party_invite(self, message): await self.party_service.accept_invite(self.player, sender) @player_idle("kick a player") - async def command_kick_player_from_party(self, message): + async def command_kick_player_from_party( + self, + message: client.KickPlayerFromParty + ): kicked_player = self.player_service.get_player(message["kicked_player_id"]) if kicked_player is None: # TODO: Client localized message @@ -1186,11 +1204,14 @@ async def command_kick_player_from_party(self, message): await self.party_service.kick_player_from_party(self.player, kicked_player) - async def command_leave_party(self, _message): + async def command_leave_party(self, message: client.LeaveParty): self.ladder_service.cancel_search(self.player) await self.party_service.leave_party(self.player) - async def command_set_party_factions(self, message): + async def command_set_party_factions( + self, + message: client.SetPartyFactions + ): factions = set(Faction.from_value(v) for v in message["factions"]) if not factions: diff --git a/server/protocol/types/__init__.py b/server/protocol/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/protocol/types/client.py b/server/protocol/types/client.py new file mode 100644 index 000000000..9764c194d --- /dev/null +++ b/server/protocol/types/client.py @@ -0,0 +1,271 @@ +from typing import Literal, TypedDict, Union + +from typing_extensions import NotRequired + + +class AcceptPartyInvite(TypedDict): + """Accept a party invite.""" + command: Literal["accept_party_invite"] + + sender_id: int + """ID of the player who sent the invite.""" + + +class Admin(TypedDict): + """Perform a special administrative action. + + Requires appropriate priviledges. + """ + command: Literal["admin"] + + action: Literal["broadcast", "closeFA", "closelobby", "join_channel"] + """The action to perform.""" + user_id: int + """The target player to perform the action on. + + Only applies to `closeFA` and `closelobby` actions. + """ + user_ids: list[int] + """The target players to perform the action on. + + Only applies to `join_channel`. + """ + channel: str + """The chat channel to join. + + Only applies to `join_channel`. + """ + message: str + """Message text to send. Can contain certain HTML styling tags. + + Only applies to `broadcast`. + """ + + +class AskSession(TypedDict): + """Request session ID information needed by the faf-uid binary.""" + command: Literal["ask_session"] + + user_agent: NotRequired[str] + """An identifier unique to the client software.""" + version: NotRequired[str] + """A version number associated with the client software. + + Typically a sequence of numbers separated by dots. + """ + + +class Auth(TypedDict): + """Log in using an OAuth token.""" + + command: Literal["auth"] + + token: str + """JWT token returned by the API.""" + unique_id: str + """String generated by the faf-uid binary.""" + + +class Avatar(TypedDict): + """Manage player avatars.""" + command: Literal["avatar"] + + action: Literal["list_avatar", "select"] + """The action to perform.""" + avatar_url: NotRequired[str] + """The URL of the avatar to select. + + Only applies to `select`. + """ + + +class CoopList(TypedDict): + """Request a list of available coop missions.""" + command: Literal["coop_list"] + + +class GameHost(TypedDict): + """Request to host a custom or coop game.""" + command: Literal["game_host"] + + visibility: Literal["public", "friends"] + """Controls which players can see the game in the lobby list.""" + title: NotRequired[str] + """Lobby title. + + Can be changed once in lobby. + """ + mod: NotRequired[str] + """Featured mod to use.""" + mapname: NotRequired[str] + """Map to use. + + Can be changed once in lobby. + """ + password: NotRequired[str] + """If set, anyone joining the game will need to enter this value.""" + enforce_rating_range: NotRequired[bool] + """Whether or not to hide the game from players who do not meet the min/max + rating requirements. + """ + rating_min: NotRequired[float] + """Minimum (displayed) global rating requirement.""" + rating_max: NotRequired[float] + """Maximum (displayed) global rating requirement.""" + + +class GameJoin(TypedDict): + """Request to join a custom game.""" + command: Literal["game_join"] + + uid: int + """ID of the game to join.""" + password: NotRequired[str] + """Optional password if joining a password protected game.""" + + +class GameMatchmaking(TypedDict): + """Request to join a matchmaker queue.""" + command: Literal["game_matchmaking"] + + queue_name: str + """Technical name of the queue to join.""" + state: Literal["start", "stop"] + """Whether to start or stop searching.""" + mod: NotRequired[str] + """Technical name of the queue to join. + + DEPRECATED: Use `queue_name` instead. + """ + faction: NotRequired[str] + """Which faction to use when the game starts. + + DEPRECATED: Use party to set faction instead. + """ + + +class Hello(TypedDict): + """Log in using a username and password. + + DEPRECATED: Use Auth instead. + """ + + command: Literal["hello"] + + login: str + """Player username.""" + password: str + """SHA256 hash of the player's password""" + unique_id: str + """String generated by the faf-uid binary.""" + + +class IceServers(TypedDict): + """Request a list of available ICE server credentials.""" + command: Literal["ice_servers"] + + +class InviteToParty(TypedDict): + """Request to send a party invite to a player.""" + command: Literal["invite_to_party"] + + recipient_id: int + """ID of the player to invite.""" + + +class KickPlayerFromParty(TypedDict): + """Request to kick a player from your party.""" + command: Literal["kick_player_from_party"] + + kicked_player_id: int + """ID of the player to kick.""" + + +class LeaveParty(TypedDict): + """Request to leave your current party.""" + command: Literal["leave_party"] + + +class MatchReady(TypedDict): + """Signal that the player has accepted their matchmaker game.""" + command: Literal["match_ready"] + + +class MatchmakerInfo(TypedDict): + command: Literal["matchmaker_info"] + + +class ModVault(TypedDict): + """ + DEPRECATED: This command will be removed in the future. Use the FAF API + instead. + """ + type: Literal["start", "like", "download"] + uid: NotRequired[int] + + +class Ping(TypedDict): + """Request a pong message.""" + command: Literal["ping"] + + +class Pong(TypedDict): + """Response to a ping message.""" + command: Literal["pong"] + + +class RestoreGameSession(TypedDict): + """Reconnect to a game after being disconnected from the lobby server. + + This only works to restore GPGNet functionality so long as the connection + to other players was never lost. Reconnection to other players is handled + by the ICE adapter. + """ + + command: Literal["restore_game_session"] + + game_id: int + """The ID of the game to reconnect to.""" + + +class SetPartyFactions(TypedDict): + """Set list of faction choices to use during matchmaker games.""" + command: Literal["set_party_factions"] + + factions: list[Union[str, int]] + """List of faction choices. + + Factions can be sent either using their string values (`aeon`, `uef`, + `cybran`, `seraphim`), or by sending their integer codes. Duplicates will be + ignored. The list must contain at least one valid choice. + + DEPRECATED: Sending factions as integers. Send strings instead. + """ + + +class SocialAdd(TypedDict): + """Add a player to the friend list or foe list. + + Exactly one of the `friend` or `foe` fields should be supplied. + """ + command: Literal["social_add"] + + friend: NotRequired[int] + """If present, indicate the player ID of the player to add as a friend""" + foe: NotRequired[int] + """If present, indicate the player ID of the player to add as a foe""" + + +class SocialRemove(TypedDict): + """Remove a player from the friend list or foe list. + + Exactly one of the `friend` or `foe` fields should be supplied. + """ + command: Literal["social_remove"] + + friend: NotRequired[int] + """If present, indicate the player ID of the player to remove from the + friend list.""" + foe: NotRequired[int] + """If present, indicate the player ID of the player to remove from the + foe list."""