Module server.lobbyconnection
Handles requests from connected clients
Classes
class LobbyConnection (database: FAFDatabase,
game_service: GameService,
players: PlayerService,
geoip: GeoIpService,
ladder_service: LadderService,
party_service: PartyService,
rating_service: RatingService,
oauth_service: OAuthService)-
Expand source code
@with_logger class LobbyConnection: @timed() def __init__( self, database: FAFDatabase, game_service: GameService, players: PlayerService, geoip: GeoIpService, ladder_service: LadderService, party_service: PartyService, rating_service: RatingService, oauth_service: OAuthService, ): self._db = database self.geoip_service = geoip self.game_service = game_service self.player_service = players self.ladder_service = ladder_service self.party_service = party_service self.rating_service = rating_service self.oauth_service = oauth_service self._authenticated = False self.player: Optional[Player] = None self.game_connection: Optional[GameConnection] = None self.peer_address: Optional[Address] = None self.session = int(random.randrange(0, 4294967295)) self.protocol: Optional[Protocol] = None self.user_agent = None self.version = None self._timeout_task = None self._attempted_connectivity_test = False self._logger.debug("LobbyConnection initialized for '%s'", self.session) @property def authenticated(self): return self._authenticated def get_user_identifier(self) -> str: """For logging purposes""" if self.player: return self.player.login return str(self.session) async def on_connection_made(self, protocol: Protocol, peername: Address): self.protocol = protocol self.peer_address = peername self._timeout_task = asyncio.create_task(self.timeout_login()) metrics.server_connections.inc() async def timeout_login(self): with contextlib.suppress(asyncio.CancelledError): await asyncio.sleep(config.LOGIN_TIMEOUT) if not self._authenticated: await self.abort("Client took too long to log in.") async def abort(self, logspam=""): self._authenticated = False self._logger.warning( "Aborting connection for '%s'. %s", self.get_user_identifier(), logspam ) if self.game_connection: await self.game_connection.abort() await self.protocol.close() async def ensure_authenticated(self, cmd): if not self._authenticated: if cmd not in ( "Bottleneck", # sent by the game during reconnect "ask_session", "auth", "create_account", "hello", "ping", "pong", ): metrics.unauth_messages.labels(cmd).inc() await self.abort(f"Message invalid for unauthenticated connection: {cmd}") return False return True async def on_message_received(self, message): """ Dispatches incoming messages """ self._logger.log(TRACE, "<< %s: %s", self.get_user_identifier(), message) try: cmd = message["command"] if not await self.ensure_authenticated(cmd): return target = message.get("target") if target == "game": if not self.game_connection: return await self.game_connection.handle_action(cmd, message.get("args", [])) return if target == "connectivity" and message.get("command") == "InitiateTest": self._attempted_connectivity_test = True raise ClientError("Your client version is no longer supported. Please update to the newest version: https://faforever.com") handler = getattr(self, f"command_{cmd}") await handler(message) except AuthenticationError as e: metrics.user_logins.labels("failure", e.method).inc() await self.send({ "command": "authentication_failed", "text": e.message }) except BanError as e: await self.send({ "command": "notice", "style": "error", "text": e.message() }) await self.abort(e.message()) except ClientError as e: self._logger.warning( "ClientError[%s]: %s", self.user_agent, e.message, ) await self.send({ "command": "notice", "style": "error", "text": e.message }) if not e.recoverable: await self.abort(e.message) except (KeyError, ValueError) as e: self._logger.exception(e) await self.abort(f"Garbage command: {message}") except ConnectionError as e: # Propagate connection errors to the ServerContext error handler. raise e except DisabledError: # TODO: Respond with correlation uid for original message await self.send({"command": "disabled", "request": cmd}) self._logger.info( "Ignoring disabled command for %s: %s", self.get_user_identifier(), cmd ) except OperationalError: # When the database goes down, SqlAlchemy will throw an OperationalError self._logger.error("Encountered OperationalError on message received. This could indicate DB is down.") await self.send({ "command": "notice", "style": "error", "text": "Unable to connect to database. Please try again later." }) # Make sure to abort here to avoid a thundering herd problem. await self.abort("Error connecting to database") except Exception as e: # pragma: no cover await self.send({"command": "invalid"}) self._logger.exception(e) await self.abort("Error processing command") def ice_only(func): """ Ensures that a handler function is not invoked from a non ICE client. """ @wraps(func) async def wrapper(self, message): if self._attempted_connectivity_test: raise ClientError("Cannot join game. Please update your client to the newest version.") return await func(self, message) return wrapper def player_idle(state_text): """ Ensures that a handler function is not invoked unless the player state is IDLE. """ def decorator(func): @wraps(func) async def wrapper(self, message): if self.player.state != PlayerState.IDLE: raise ClientError( f"Can't {state_text} while in state " f"{self.player.state.name}", recoverable=True ) return await func(self, message) return wrapper return decorator async def command_ping(self, msg): await self.send({"command": "pong"}) async def command_pong(self, msg): pass async def command_create_account(self, message): raise ClientError("FAF no longer supports direct registration. Please use the website to register.", recoverable=True) async def command_coop_list(self, message): """Request for coop map list""" async with self._db.acquire() as conn: result = await conn.stream(select(coop_map)) campaigns = [ "FA Campaign", "Aeon Vanilla Campaign", "Cybran Vanilla Campaign", "UEF Vanilla Campaign", "Custom Missions" ] async for row in result: if row.type >= len(campaigns): # Don't sent corrupt data to the client... self._logger.error("Unknown coop type! %s", row.type) continue await self.send({ "command": "coop_info", "uid": row.id, "type": campaigns[row.type], "name": row.name, "description": row.description, "filename": row.filename, "featured_mod": "coop" }) async def command_matchmaker_info(self, message): await self.send({ "command": "matchmaker_info", "queues": [ queue.to_dict() for queue in self.ladder_service.queues.values() if queue.is_running ] }) async def send_game_list(self): await self.send({ "command": "game_info", "games": [ game.to_dict() for game in self.game_service.open_games if game.is_visible_to_player(self.player) ] }) async def command_social_remove(self, message): if "friend" in message: subject_id = message["friend"] player_attr = self.player.friends elif "foe" in message: subject_id = message["foe"] player_attr = self.player.foes else: await self.abort("No-op social_remove.") return async with self._db.acquire() as conn: await conn.execute(friends_and_foes.delete().where(and_( friends_and_foes.c.user_id == self.player.id, friends_and_foes.c.subject_id == subject_id ))) game = self.player.game visibility_context_manager = contextlib.nullcontext() if game and game.host == self.player: # If the player is currently hosting a game, we need to make sure # that the visibility change is sent to the subject subject = self.player_service.get_player(subject_id) visibility_context_manager = self._write_visibility_change_context( game, subject, ) with visibility_context_manager: player_attr.discard(subject_id) async def command_social_add(self, message): if "friend" in message: status = "FRIEND" subject_id = message["friend"] player_attr = self.player.friends elif "foe" in message: status = "FOE" subject_id = message["foe"] player_attr = self.player.foes else: return if subject_id in player_attr: return async with self._db.acquire() as conn: await conn.execute(friends_and_foes.insert().values( user_id=self.player.id, status=status, subject_id=subject_id, )) game = self.player.game visibility_context_manager = contextlib.nullcontext() if game and game.host == self.player: # If the player is currently hosting a game, we need to make sure # that the visibility change is sent to the subject subject = self.player_service.get_player(subject_id) visibility_context_manager = self._write_visibility_change_context( game, subject, ) with visibility_context_manager: player_attr.add(subject_id) @contextlib.contextmanager def _write_visibility_change_context( self, game: Game, player: Player, ): # Check visibility before/after was_visible = game.is_visible_to_player(player) yield is_visible = game.is_visible_to_player(player) if was_visible is is_visible: return self._logger.debug( "Visibility for %s changed for %s from %s -> %s", game, player.login, was_visible, is_visible, ) msg = game.to_dict() if not is_visible: msg["state"] = "closed" player.write_message(msg) async def kick(self): await self.send({ "command": "notice", "style": "kick", }) await self.abort() async def send_updated_achievements(self, updated_achievements): await self.send({ "command": "updated_achievements", "updated_achievements": updated_achievements }) async def command_admin(self, message): action = message["action"] if action == "closeFA": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): player = self.player_service[message["user_id"]] if player: self._logger.info( "Administrative action: %s closed game for %s", self.player, player ) player.write_message({ "command": "notice", "style": "kill", }) elif action == "closelobby": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): player = self.player_service[message["user_id"]] if player and player.lobby_connection is not None: self._logger.info( "Administrative action: %s closed client for %s", self.player, player ) with contextlib.suppress(DisconnectedError): await player.lobby_connection.kick() elif action == "broadcast": message_text = message.get("message") if not message_text: return if await self.player_service.has_permission_role( self.player, "ADMIN_BROADCAST_MESSAGE" ): for player in self.player_service: # Check if object still exists: # https://docs.python.org/3/library/weakref.html#weak-reference-objects if player.lobby_connection is not None: with contextlib.suppress(DisconnectedError): player.lobby_connection.write_warning(message_text) self._logger.info( "%s broadcasting message to all players: %s", self.player.login, message_text ) elif action == "join_channel": if await self.player_service.has_permission_role( self.player, "ADMIN_JOIN_CHANNEL" ): user_ids = message["user_ids"] channel = message["channel"] for user_id in user_ids: player = self.player_service[user_id] if player: player.write_message({ "command": "social", "autojoin": [channel] }) async def check_user_login(self, conn, username, password): # TODO: Hash passwords server-side so the hashing actually *does* something. result = await conn.execute( select( t_login.c.id, t_login.c.login, t_login.c.password, lobby_ban.c.reason, lobby_ban.c.expires_at ).select_from(t_login.outerjoin(lobby_ban)) .where(t_login.c.login == username) .order_by(lobby_ban.c.expires_at.desc()) ) auth_method = "password" auth_error_message = "Login not found or password incorrect. They are case sensitive." row = result.fetchone() if not row: raise AuthenticationError(auth_error_message, auth_method) player_id = row.id real_username = row.login dbPassword = row.password ban_reason = row.reason ban_expiry = row.expires_at if dbPassword != password: raise AuthenticationError(auth_error_message, auth_method) now = datetime.utcnow() if ban_reason is not None and now < ban_expiry: self._logger.debug( "Rejected login from banned user: %s, %s, %s", player_id, username, self.session ) raise BanError(ban_expiry, ban_reason) return player_id, real_username def _set_user_agent_and_version(self, user_agent, version): metrics.user_connections.labels(str(self.user_agent), str(self.version)).dec() self.user_agent = user_agent # only count a new version if it previously wasn't set # to avoid double counting if self.version is None and version is not None: metrics.user_agent_version.labels(str(version)).inc() self.version = version metrics.user_connections.labels(str(self.user_agent), str(self.version)).inc() async def _check_user_agent(self): if not self.user_agent or "downlords-faf-client" not in self.user_agent: await self.send_warning( "You are using an unofficial client version! " "Some features might not work as expected. " "If you experience any problems please download the latest " "version of the official client from " f'<a href="{config.WWW_URL}">{config.WWW_URL}</a>' ) async def check_policy_conformity(self, player_id, uid_hash, session, ignore_result=False): if not config.USE_POLICY_SERVER: return True url = config.FAF_POLICY_SERVER_BASE_URL + "/verify" payload = { "player_id": player_id, "uid_hash": uid_hash, "session": session } headers = { "content-type": "application/json", "cache-control": "no-cache" } async with aiohttp.ClientSession(raise_for_status=True) as session: async with session.post(url, json=payload, headers=headers) as resp: response = await resp.json() if ignore_result: return True if response.get("result", "") == "already_associated": self._logger.warning("UID hit: %d: %s", player_id, uid_hash) await self.send_warning("Your computer is already associated with another FAF account.<br><br>In order to " "log in with an additional account, you have to link it to Steam: <a href='" + config.WWW_URL + "/account/link'>" + config.WWW_URL + "/account/link</a>.<br>If you need an exception, please contact an " "admin or moderator on the forums", fatal=True) return False if response.get("result", "") == "fraudulent": self._logger.info("Banning player %s for fraudulent looking login.", player_id) await self.send_warning("Fraudulent login attempt detected. As a precautionary measure, your account has been " "banned permanently. Please contact an admin or moderator on the forums if you feel this is " "a false positive.", fatal=True) async with self._db.acquire() as conn: try: ban_reason = "Auto-banned because of fraudulent login attempt" ban_level = "GLOBAL" await conn.execute( ban.insert().values( player_id=player_id, author_id=player_id, reason=ban_reason, level=ban_level, ) ) except DBAPIError as e: raise ClientError(f"Banning failed: {e}") return False return response.get("result", "") == "honest" async def command_auth(self, message): token = message["token"] unique_id = message["unique_id"] player_id = await self.oauth_service.get_player_id_from_token(token) auth_method = "token" async with self._db.acquire() as conn: result = await conn.execute( select( t_login.c.login, lobby_ban.c.reason, lobby_ban.c.expires_at ) .select_from(t_login.outerjoin(lobby_ban)) .where(t_login.c.id == player_id) .order_by(lobby_ban.c.expires_at.desc()) ) row = result.fetchone() if not row: self._logger.warning( "User id %s not found in database! Possible fraudulent " "token: %s", player_id, token ) raise AuthenticationError("Cannot find user id", auth_method) username = row.login ban_reason = row.reason ban_expiry = row.expires_at now = datetime.utcnow() if ban_reason is not None and now < ban_expiry: self._logger.debug( "Rejected login from banned user: %s, %s, %s", player_id, username, self.session ) raise BanError(ban_expiry, ban_reason) # DEPRECATED: IRC passwords are handled outside of the lobby server. # This message remains here for backwards compatibility, but the data # sent is meaningless and can be ignored by clients. await self.send({ "command": "irc_password", "password": "deprecated" }) await self.on_player_login( player_id, username, unique_id, auth_method ) async def command_hello(self, message): login = message["login"].strip() password = message["password"] unique_id = message["unique_id"] if not config.ALLOW_PASSWORD_LOGIN: self._logger.debug( "Rejected login from user: %s, %s", login, self.session ) raise ClientError( "Username password login has been disabled please use " "a different client to login", recoverable=False ) async with self._db.acquire() as conn: player_id, username = await self.check_user_login( conn, login, password ) await self.on_player_login( player_id, username, unique_id, "password" ) async def on_player_login( self, player_id: int, username: str, unique_id: str, method: str ): conforms_policy = await self.check_policy_conformity( player_id, unique_id, self.session, # All players are required to have game ownership verified # so this is for informational purposes only ignore_result=True ) if not conforms_policy: return old_player = self.player_service.get_player(player_id) if old_player: self._logger.debug( "player %s already signed in: %s", player_id, old_player ) if old_player.lobby_connection is self: await self.send_warning( "You are already signed in from this location!" ) return elif old_player.lobby_connection is not None: with contextlib.suppress(DisconnectedError): old_player.lobby_connection.write_warning( "You have been signed out because you signed in " "elsewhere.", fatal=True, style="kick" ) self._logger.info( "Login from: %s(id=%s), using method '%s' for session %s", username, player_id, method, self.session ) metrics.user_logins.labels("success", method).inc() async with self._db.acquire() as conn: await conn.execute( t_login.update().where( t_login.c.id == player_id ).values( ip=self.peer_address.host, user_agent=self.user_agent, last_login=func.now() ) ) self.player = Player( login=username, session=self.session, player_id=player_id, lobby_connection=self, leaderboards=self.rating_service.leaderboards ) await self.player_service.fetch_player_data(self.player) self.player_service[self.player.id] = self.player self._authenticated = True # Country # ------- self.player.country = self.geoip_service.country(self.peer_address.host) # Send the player their own player info. await self.send({ "command": "welcome", "me": self.player.to_dict(), "current_time": datetime_now().isoformat(), # For backwards compatibility for old clients. For now. "id": self.player.id, "login": username }) # Tell player about everybody online. This must happen after "welcome". await self.send({ "command": "player_info", "players": [player.to_dict() for player in self.player_service] }) # Tell everyone else online about us. This must happen after all the player_info messages. # This ensures that no other client will perform an operation that interacts with the # incoming user, allowing the client to make useful assumptions: it can be certain it has # initialised its local player service before it is going to get messages that want to # query it. self.player_service.mark_dirty(self.player) friends = [] foes = [] async with self._db.acquire() as conn: result = await conn.execute( select( friends_and_foes.c.subject_id, friends_and_foes.c.status ).where( friends_and_foes.c.user_id == self.player.id ) ) for row in result: if row.status == "FRIEND": friends.append(row.subject_id) else: foes.append(row.subject_id) self.player.friends = set(friends) self.player.foes = set(foes) channels = [] if self.player.is_moderator(): channels.append("#moderators") if self.player.clan is not None: channels.append(f"#{self.player.clan}_clan") json_to_send = { "command": "social", "autojoin": channels, "channels": channels, "friends": friends, "foes": foes, "power": self.player.power() } await self.send(json_to_send) await self.send_game_list() @ice_only @player_idle("reconnect to a game") async def command_restore_game_session(self, message): assert self.player is not None 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 no longer exists") return game: Game = self.game_service[game_id] if game.state not in (GameState.LOBBY, GameState.LIVE): # NOTE: Getting here is only possible if you join within the # 1 second window between the game ending and the game being removed # from the game service. await self.send_warning("The game you were connected to is no longer available") return if ( game.state is GameState.LIVE and self.player.id not in (player.id for player in game.players) ): await self.send_warning("You are not part of this game") return self._logger.info("Restoring game session of player %s to game %s", self.player, game) self.game_connection = GameConnection( database=self._db, game=game, player=self.player, protocol=self.protocol, player_service=self.player_service, games=self.game_service, state=GameConnectionState.CONNECTED_TO_HOST ) game.add_game_connection(self.game_connection) self.player.state = PlayerState.PLAYING self.player.game = game async def command_ask_session(self, message): 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): action = message["action"] if action == "list_avatar": async with self._db.acquire() as conn: result = await conn.execute( select( avatars_list.c.url, avatars_list.c.tooltip ).select_from( avatars.outerjoin( avatars_list ) ).where( avatars.c.idUser == self.player.id ) ) await self.send({ "command": "avatar", "avatarlist": [ {"url": row.url, "tooltip": row.tooltip} for row in result ] }) elif action == "select": avatar_url = message["avatar"] async with self._db.acquire() as conn: if avatar_url is not None: result = await conn.execute( select( avatars_list.c.id, avatars_list.c.tooltip ).select_from( avatars.join(avatars_list) ).where( and_( avatars_list.c.url == avatar_url, avatars.c.idUser == self.player.id ) ) ) row = result.fetchone() if not row: return await conn.execute( avatars.update().where( avatars.c.idUser == self.player.id ).values( selected=0 ) ) self.player.avatar = None if avatar_url is not None: await conn.execute( avatars.update().where( and_( avatars.c.idUser == self.player.id, avatars.c.idAvatar == row.id ) ).values( selected=1 ) ) self.player.avatar = { "url": avatar_url, "tooltip": row.tooltip } self.player_service.mark_dirty(self.player) else: raise KeyError("invalid action") @ice_only @player_idle("join a game") async def command_game_join(self, message): """ We are going to join a game. """ assert isinstance(self.player, Player) await self.abort_connection_if_banned() uuid = int(message["uid"]) password = message.get("password") self._logger.debug("joining: %d with pw: %s", uuid, password) try: game = self.game_service[uuid] except KeyError: await self.send({ "command": "notice", "style": "info", "text": "The host has left the game." }) return if self.player.id in game.host.foes: raise ClientError("You cannot join games hosted by this player.") if not game or game.state is not GameState.LOBBY: self._logger.debug("Game not in lobby state: %s state %s", game, game.state) await self.send({ "command": "notice", "style": "info", "text": "The game you are trying to join is not ready." }) return if game.init_mode != InitMode.NORMAL_LOBBY: raise ClientError("The game cannot be joined in this way.") if game.password != password: await self.send({ "command": "notice", "style": "info", "text": "Bad password (it's case sensitive)." }) return await self.launch_game(game, is_host=False) @ice_only async def command_game_matchmaking(self, message): queue_name = str( message.get("queue_name") or message.get("mod", "ladder1v1") ) state = str(message["state"]) if state == "stop": self.ladder_service.cancel_search(self.player, queue_name) return party = self.party_service.get_party(self.player) if self.player != party.owner: raise ClientError( "Only the party owner may enter the party into a queue.", recoverable=True ) for member in party: player = member.player if player.state not in ( PlayerState.IDLE, PlayerState.SEARCHING_LADDER ): raise ClientError( f"Can't join a queue while {player.login} is in state " f"{player.state.name}", recoverable=True ) if state == "start": players = party.players if len(players) > self.ladder_service.queues[queue_name].team_size: raise ClientError( "Your party is too large to join that queue!", recoverable=True ) # TODO: Remove this legacy behavior, use party instead if "faction" in message: party.set_factions( self.player, [Faction.from_value(message["faction"])] ) self.ladder_service.start_search( players, queue_name=queue_name, on_matched=party.on_matched ) @ice_only @player_idle("host a game") async def command_game_host(self, message): assert isinstance(self.player, Player) await self.abort_connection_if_banned() visibility = VisibilityState(message["visibility"]) title = message.get("title") or f"{self.player.login}'s game" if not title.isascii(): raise ClientError("Title must contain only ascii characters.") if not title.strip(): raise ClientError("Title must not be empty.") mod = message.get("mod") or FeaturedModType.FAF mapname = message.get("mapname") or "scmp_007" game_map = await self.game_service.get_map(mapname) password = message.get("password") game_mode = mod.lower() rating_min = message.get("rating_min") rating_max = message.get("rating_max") enforce_rating_range = bool(message.get("enforce_rating_range", False)) if rating_min is not None: rating_min = float(rating_min) if rating_max is not None: rating_max = float(rating_max) game_class = CoopGame if game_mode == FeaturedModType.COOP else CustomGame game = self.game_service.create_game( visibility=visibility, game_mode=game_mode, game_class=game_class, host=self.player, name=title, map=game_map, password=password, rating_type=RatingType.GLOBAL, displayed_rating_range=InclusiveRange(rating_min, rating_max), enforce_rating_range=enforce_rating_range ) await self.launch_game(game, is_host=True) async def command_match_ready(self, message): """ Replace with full implementation when implemented in client, see: https://github.com/FAForever/downlords-faf-client/issues/1783 """ pass async def launch_game( self, game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), ) -> None: if self.game_connection: await self.game_connection.abort("Player launched a new game") self.game_connection = None await self.send(self._prepare_launch_game( game, is_host=is_host, options=options )) def write_launch_game( self, game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), ) -> None: if self.game_connection is not None: self._logger.warning( "%s launched a new game while old GameConnection was active", self.player ) self.game_connection = None self.write(self._prepare_launch_game( game, is_host=is_host, options=options )) def _prepare_launch_game( self, game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), ): assert self.player is not None assert self.game_connection is None assert self.player.state in ( PlayerState.IDLE, PlayerState.STARTING_AUTOMATCH, ) # TODO: Fix setting up a ridiculous amount of cyclic pointers here if is_host: game.host = self.player self.game_connection = GameConnection( database=self._db, game=game, player=self.player, protocol=self.protocol, player_service=self.player_service, games=self.game_service, setup_timeout=game.setup_timeout, ) if self.player.state is PlayerState.IDLE: self.player.state = PlayerState.STARTING_GAME self.player.game = game cmd = { "command": "game_launch", "args": ["/numgames", self.player.game_count[game.rating_type]], "uid": game.id, "mod": game.game_mode, # Following parameters may not be used by the client yet. They are # needed for setting up auto-lobby style matches such as ladder, gw, # and team machmaking where the server decides what these game # options are. Currently, options for ladder are hardcoded into the # client. "name": game.name, # DEPRICATED: init_mode can be inferred from game_type "init_mode": game.init_mode.value, "game_type": game.game_type.value, "rating_type": game.rating_type, **options._asdict() } return {k: v for k, v in cmd.items() if v is not None} async def command_modvault(self, message): type = message["type"] async with self._db.acquire() as conn: if type == "start": result = await conn.execute("SELECT uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon FROM table_mod ORDER BY likes DESC LIMIT 100") for row in result: uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon = (row[i] for i in range(12)) try: link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) thumbstr = "" if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], comments=[], description=description, played=played, likes=likes, downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, ui=ui) await self.send(out) except Exception: self._logger.error(f"Error handling table_mod row (uid: {uid})", exc_info=True) elif type == "like": canLike = True uid = message["uid"] result = await conn.execute( "SELECT uid, name, version, author, ui, date, downloads, " "likes, played, description, filename, icon, likers FROM " "`table_mod` WHERE uid = :uid LIMIT 1", uid=uid ) row = result.fetchone() uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon, likerList = (row[i] for i in range(13)) link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) thumbstr = "" if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], comments=[], description=description, played=played, likes=likes + 1, downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, ui=ui) try: likers = json.loads(likerList) if self.player.id in likers: canLike = False else: likers.append(self.player.id) except Exception: likers = [] # TODO: Avoid sending all the mod info in the world just because we liked it? if canLike: await conn.execute( "UPDATE mod_stats s " "JOIN mod_version v ON v.mod_id = s.mod_id " "SET s.likes = s.likes + 1, likers=:l WHERE v.uid=:id", l=json.dumps(likers), id=uid ) await self.send(out) elif type == "download": uid = message["uid"] await conn.execute( "UPDATE mod_stats s " "JOIN mod_version v ON v.mod_id = s.mod_id " "SET downloads=downloads+1 WHERE v.uid = %s", uid) else: raise ValueError("invalid type argument") # DEPRECATED: ICE servers are handled outside of the lobby server. # This message remains here for backwards compatibility, but the list # of servers will always be empty. async def command_ice_servers(self, message): if not self.player: return await self.send({ "command": "ice_servers", "ice_servers": [], }) @player_idle("invite a player") async def command_invite_to_party(self, message): recipient = self.player_service.get_player(message["recipient_id"]) if recipient is None: # TODO: Client localized message raise ClientError("The invited player doesn't exist", recoverable=True) if self.player.id in recipient.foes: return self.party_service.invite_player_to_party(self.player, recipient) @player_idle("join a party") async def command_accept_party_invite(self, message): sender = self.player_service.get_player(message["sender_id"]) if sender is None: # TODO: Client localized message raise ClientError("The inviting player doesn't exist", recoverable=True) await self.party_service.accept_invite(self.player, sender) @player_idle("kick a player") async def command_kick_player_from_party(self, message): kicked_player = self.player_service.get_player(message["kicked_player_id"]) if kicked_player is None: # TODO: Client localized message raise ClientError("The kicked player doesn't exist", recoverable=True) await self.party_service.kick_player_from_party(self.player, kicked_player) async def command_leave_party(self, _message): self.ladder_service.cancel_search(self.player) await self.party_service.leave_party(self.player) async def command_set_party_factions(self, message): factions = set(Faction.from_value(v) for v in message["factions"]) if not factions: raise ClientError( "You must select at least one faction.", recoverable=True ) self.party_service.set_factions(self.player, list(factions)) async def send_warning(self, message: str, fatal: bool = False): """ Display a warning message to the client # Params - `message`: Warning message to display - `fatal`: Whether or not the warning is fatal. If the client receives a fatal warning it should disconnect and not attempt to reconnect. """ await self.send({ "command": "notice", "style": "info" if not fatal else "error", "text": message }) if fatal: await self.abort(message) def write_warning( self, message: str, fatal: bool = False, style: Optional[str] = None ): """ Like `send_warning`, but does not await the data to be sent. """ self.write({ "command": "notice", "style": style or ("info" if not fatal else "error"), "text": message }) if fatal: asyncio.create_task(self.abort(message)) async def send(self, message): """Send a message and wait for it to be sent.""" self.write(message) await self.protocol.drain() def write(self, message): """Write a message into the send buffer.""" self._logger.log(TRACE, ">> %s: %s", self.get_user_identifier(), message) self.protocol.write_message(message) async def on_connection_lost(self): async def nop(*args, **kwargs): return self.send = nop if self._timeout_task and not self._timeout_task.done(): self._timeout_task.cancel() if self.game_connection: self._logger.debug( "Lost lobby connection killing game connection for player %s", self.game_connection.player.id ) await self.game_connection.on_connection_lost() async def abort_connection_if_banned(self): async with self._db.acquire() as conn: now = datetime.utcnow() result = await conn.execute( select(lobby_ban.c.reason, lobby_ban.c.expires_at) .where(lobby_ban.c.idUser == self.player.id) .order_by(lobby_ban.c.expires_at.desc()) ) row = result.fetchone() if row is None: return ban_expiry = row.expires_at ban_reason = row.reason if now < ban_expiry: self._logger.debug( "Aborting connection of banned user: %s, %s, %s", self.player.id, self.player.login, self.session ) raise BanError(ban_expiry, ban_reason)
Instance variables
prop authenticated
-
Expand source code
@property def authenticated(self): return self._authenticated
Methods
async def abort(self, logspam='')
-
Expand source code
async def abort(self, logspam=""): self._authenticated = False self._logger.warning( "Aborting connection for '%s'. %s", self.get_user_identifier(), logspam ) if self.game_connection: await self.game_connection.abort() await self.protocol.close()
async def abort_connection_if_banned(self)
-
Expand source code
async def abort_connection_if_banned(self): async with self._db.acquire() as conn: now = datetime.utcnow() result = await conn.execute( select(lobby_ban.c.reason, lobby_ban.c.expires_at) .where(lobby_ban.c.idUser == self.player.id) .order_by(lobby_ban.c.expires_at.desc()) ) row = result.fetchone() if row is None: return ban_expiry = row.expires_at ban_reason = row.reason if now < ban_expiry: self._logger.debug( "Aborting connection of banned user: %s, %s, %s", self.player.id, self.player.login, self.session ) raise BanError(ban_expiry, ban_reason)
async def check_policy_conformity(self, player_id, uid_hash, session, ignore_result=False)
-
Expand source code
async def check_policy_conformity(self, player_id, uid_hash, session, ignore_result=False): if not config.USE_POLICY_SERVER: return True url = config.FAF_POLICY_SERVER_BASE_URL + "/verify" payload = { "player_id": player_id, "uid_hash": uid_hash, "session": session } headers = { "content-type": "application/json", "cache-control": "no-cache" } async with aiohttp.ClientSession(raise_for_status=True) as session: async with session.post(url, json=payload, headers=headers) as resp: response = await resp.json() if ignore_result: return True if response.get("result", "") == "already_associated": self._logger.warning("UID hit: %d: %s", player_id, uid_hash) await self.send_warning("Your computer is already associated with another FAF account.<br><br>In order to " "log in with an additional account, you have to link it to Steam: <a href='" + config.WWW_URL + "/account/link'>" + config.WWW_URL + "/account/link</a>.<br>If you need an exception, please contact an " "admin or moderator on the forums", fatal=True) return False if response.get("result", "") == "fraudulent": self._logger.info("Banning player %s for fraudulent looking login.", player_id) await self.send_warning("Fraudulent login attempt detected. As a precautionary measure, your account has been " "banned permanently. Please contact an admin or moderator on the forums if you feel this is " "a false positive.", fatal=True) async with self._db.acquire() as conn: try: ban_reason = "Auto-banned because of fraudulent login attempt" ban_level = "GLOBAL" await conn.execute( ban.insert().values( player_id=player_id, author_id=player_id, reason=ban_reason, level=ban_level, ) ) except DBAPIError as e: raise ClientError(f"Banning failed: {e}") return False return response.get("result", "") == "honest"
async def check_user_login(self, conn, username, password)
-
Expand source code
async def check_user_login(self, conn, username, password): # TODO: Hash passwords server-side so the hashing actually *does* something. result = await conn.execute( select( t_login.c.id, t_login.c.login, t_login.c.password, lobby_ban.c.reason, lobby_ban.c.expires_at ).select_from(t_login.outerjoin(lobby_ban)) .where(t_login.c.login == username) .order_by(lobby_ban.c.expires_at.desc()) ) auth_method = "password" auth_error_message = "Login not found or password incorrect. They are case sensitive." row = result.fetchone() if not row: raise AuthenticationError(auth_error_message, auth_method) player_id = row.id real_username = row.login dbPassword = row.password ban_reason = row.reason ban_expiry = row.expires_at if dbPassword != password: raise AuthenticationError(auth_error_message, auth_method) now = datetime.utcnow() if ban_reason is not None and now < ban_expiry: self._logger.debug( "Rejected login from banned user: %s, %s, %s", player_id, username, self.session ) raise BanError(ban_expiry, ban_reason) return player_id, real_username
async def command_accept_party_invite(self, message)
-
Expand source code
@player_idle("join a party") async def command_accept_party_invite(self, message): sender = self.player_service.get_player(message["sender_id"]) if sender is None: # TODO: Client localized message raise ClientError("The inviting player doesn't exist", recoverable=True) await self.party_service.accept_invite(self.player, sender)
async def command_admin(self, message)
-
Expand source code
async def command_admin(self, message): action = message["action"] if action == "closeFA": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): player = self.player_service[message["user_id"]] if player: self._logger.info( "Administrative action: %s closed game for %s", self.player, player ) player.write_message({ "command": "notice", "style": "kill", }) elif action == "closelobby": if await self.player_service.has_permission_role( self.player, "ADMIN_KICK_SERVER" ): player = self.player_service[message["user_id"]] if player and player.lobby_connection is not None: self._logger.info( "Administrative action: %s closed client for %s", self.player, player ) with contextlib.suppress(DisconnectedError): await player.lobby_connection.kick() elif action == "broadcast": message_text = message.get("message") if not message_text: return if await self.player_service.has_permission_role( self.player, "ADMIN_BROADCAST_MESSAGE" ): for player in self.player_service: # Check if object still exists: # https://docs.python.org/3/library/weakref.html#weak-reference-objects if player.lobby_connection is not None: with contextlib.suppress(DisconnectedError): player.lobby_connection.write_warning(message_text) self._logger.info( "%s broadcasting message to all players: %s", self.player.login, message_text ) elif action == "join_channel": if await self.player_service.has_permission_role( self.player, "ADMIN_JOIN_CHANNEL" ): user_ids = message["user_ids"] channel = message["channel"] for user_id in user_ids: player = self.player_service[user_id] if player: player.write_message({ "command": "social", "autojoin": [channel] })
async def command_ask_session(self, message)
-
Expand source code
async def command_ask_session(self, message): 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_auth(self, message)
-
Expand source code
async def command_auth(self, message): token = message["token"] unique_id = message["unique_id"] player_id = await self.oauth_service.get_player_id_from_token(token) auth_method = "token" async with self._db.acquire() as conn: result = await conn.execute( select( t_login.c.login, lobby_ban.c.reason, lobby_ban.c.expires_at ) .select_from(t_login.outerjoin(lobby_ban)) .where(t_login.c.id == player_id) .order_by(lobby_ban.c.expires_at.desc()) ) row = result.fetchone() if not row: self._logger.warning( "User id %s not found in database! Possible fraudulent " "token: %s", player_id, token ) raise AuthenticationError("Cannot find user id", auth_method) username = row.login ban_reason = row.reason ban_expiry = row.expires_at now = datetime.utcnow() if ban_reason is not None and now < ban_expiry: self._logger.debug( "Rejected login from banned user: %s, %s, %s", player_id, username, self.session ) raise BanError(ban_expiry, ban_reason) # DEPRECATED: IRC passwords are handled outside of the lobby server. # This message remains here for backwards compatibility, but the data # sent is meaningless and can be ignored by clients. await self.send({ "command": "irc_password", "password": "deprecated" }) await self.on_player_login( player_id, username, unique_id, auth_method )
async def command_avatar(self, message)
-
Expand source code
async def command_avatar(self, message): action = message["action"] if action == "list_avatar": async with self._db.acquire() as conn: result = await conn.execute( select( avatars_list.c.url, avatars_list.c.tooltip ).select_from( avatars.outerjoin( avatars_list ) ).where( avatars.c.idUser == self.player.id ) ) await self.send({ "command": "avatar", "avatarlist": [ {"url": row.url, "tooltip": row.tooltip} for row in result ] }) elif action == "select": avatar_url = message["avatar"] async with self._db.acquire() as conn: if avatar_url is not None: result = await conn.execute( select( avatars_list.c.id, avatars_list.c.tooltip ).select_from( avatars.join(avatars_list) ).where( and_( avatars_list.c.url == avatar_url, avatars.c.idUser == self.player.id ) ) ) row = result.fetchone() if not row: return await conn.execute( avatars.update().where( avatars.c.idUser == self.player.id ).values( selected=0 ) ) self.player.avatar = None if avatar_url is not None: await conn.execute( avatars.update().where( and_( avatars.c.idUser == self.player.id, avatars.c.idAvatar == row.id ) ).values( selected=1 ) ) self.player.avatar = { "url": avatar_url, "tooltip": row.tooltip } self.player_service.mark_dirty(self.player) else: raise KeyError("invalid action")
async def command_coop_list(self, message)
-
Expand source code
async def command_coop_list(self, message): """Request for coop map list""" async with self._db.acquire() as conn: result = await conn.stream(select(coop_map)) campaigns = [ "FA Campaign", "Aeon Vanilla Campaign", "Cybran Vanilla Campaign", "UEF Vanilla Campaign", "Custom Missions" ] async for row in result: if row.type >= len(campaigns): # Don't sent corrupt data to the client... self._logger.error("Unknown coop type! %s", row.type) continue await self.send({ "command": "coop_info", "uid": row.id, "type": campaigns[row.type], "name": row.name, "description": row.description, "filename": row.filename, "featured_mod": "coop" })
Request for coop map list
async def command_create_account(self, message)
-
Expand source code
async def command_create_account(self, message): raise ClientError("FAF no longer supports direct registration. Please use the website to register.", recoverable=True)
async def command_game_host(self, message)
-
Expand source code
@ice_only @player_idle("host a game") async def command_game_host(self, message): assert isinstance(self.player, Player) await self.abort_connection_if_banned() visibility = VisibilityState(message["visibility"]) title = message.get("title") or f"{self.player.login}'s game" if not title.isascii(): raise ClientError("Title must contain only ascii characters.") if not title.strip(): raise ClientError("Title must not be empty.") mod = message.get("mod") or FeaturedModType.FAF mapname = message.get("mapname") or "scmp_007" game_map = await self.game_service.get_map(mapname) password = message.get("password") game_mode = mod.lower() rating_min = message.get("rating_min") rating_max = message.get("rating_max") enforce_rating_range = bool(message.get("enforce_rating_range", False)) if rating_min is not None: rating_min = float(rating_min) if rating_max is not None: rating_max = float(rating_max) game_class = CoopGame if game_mode == FeaturedModType.COOP else CustomGame game = self.game_service.create_game( visibility=visibility, game_mode=game_mode, game_class=game_class, host=self.player, name=title, map=game_map, password=password, rating_type=RatingType.GLOBAL, displayed_rating_range=InclusiveRange(rating_min, rating_max), enforce_rating_range=enforce_rating_range ) await self.launch_game(game, is_host=True)
async def command_game_join(self, message)
-
Expand source code
@ice_only @player_idle("join a game") async def command_game_join(self, message): """ We are going to join a game. """ assert isinstance(self.player, Player) await self.abort_connection_if_banned() uuid = int(message["uid"]) password = message.get("password") self._logger.debug("joining: %d with pw: %s", uuid, password) try: game = self.game_service[uuid] except KeyError: await self.send({ "command": "notice", "style": "info", "text": "The host has left the game." }) return if self.player.id in game.host.foes: raise ClientError("You cannot join games hosted by this player.") if not game or game.state is not GameState.LOBBY: self._logger.debug("Game not in lobby state: %s state %s", game, game.state) await self.send({ "command": "notice", "style": "info", "text": "The game you are trying to join is not ready." }) return if game.init_mode != InitMode.NORMAL_LOBBY: raise ClientError("The game cannot be joined in this way.") if game.password != password: await self.send({ "command": "notice", "style": "info", "text": "Bad password (it's case sensitive)." }) return await self.launch_game(game, is_host=False)
We are going to join a game.
async def command_game_matchmaking(self, message)
-
Expand source code
@ice_only async def command_game_matchmaking(self, message): queue_name = str( message.get("queue_name") or message.get("mod", "ladder1v1") ) state = str(message["state"]) if state == "stop": self.ladder_service.cancel_search(self.player, queue_name) return party = self.party_service.get_party(self.player) if self.player != party.owner: raise ClientError( "Only the party owner may enter the party into a queue.", recoverable=True ) for member in party: player = member.player if player.state not in ( PlayerState.IDLE, PlayerState.SEARCHING_LADDER ): raise ClientError( f"Can't join a queue while {player.login} is in state " f"{player.state.name}", recoverable=True ) if state == "start": players = party.players if len(players) > self.ladder_service.queues[queue_name].team_size: raise ClientError( "Your party is too large to join that queue!", recoverable=True ) # TODO: Remove this legacy behavior, use party instead if "faction" in message: party.set_factions( self.player, [Faction.from_value(message["faction"])] ) self.ladder_service.start_search( players, queue_name=queue_name, on_matched=party.on_matched )
async def command_hello(self, message)
-
Expand source code
async def command_hello(self, message): login = message["login"].strip() password = message["password"] unique_id = message["unique_id"] if not config.ALLOW_PASSWORD_LOGIN: self._logger.debug( "Rejected login from user: %s, %s", login, self.session ) raise ClientError( "Username password login has been disabled please use " "a different client to login", recoverable=False ) async with self._db.acquire() as conn: player_id, username = await self.check_user_login( conn, login, password ) await self.on_player_login( player_id, username, unique_id, "password" )
async def command_ice_servers(self, message)
-
Expand source code
async def command_ice_servers(self, message): if not self.player: return await self.send({ "command": "ice_servers", "ice_servers": [], })
async def command_invite_to_party(self, message)
-
Expand source code
@player_idle("invite a player") async def command_invite_to_party(self, message): recipient = self.player_service.get_player(message["recipient_id"]) if recipient is None: # TODO: Client localized message raise ClientError("The invited player doesn't exist", recoverable=True) if self.player.id in recipient.foes: return self.party_service.invite_player_to_party(self.player, recipient)
async def command_kick_player_from_party(self, message)
-
Expand source code
@player_idle("kick a player") async def command_kick_player_from_party(self, message): kicked_player = self.player_service.get_player(message["kicked_player_id"]) if kicked_player is None: # TODO: Client localized message raise ClientError("The kicked player doesn't exist", recoverable=True) await self.party_service.kick_player_from_party(self.player, kicked_player)
async def command_leave_party(self, _message)
-
Expand source code
async def command_leave_party(self, _message): self.ladder_service.cancel_search(self.player) await self.party_service.leave_party(self.player)
async def command_match_ready(self, message)
-
Expand source code
async def command_match_ready(self, message): """ Replace with full implementation when implemented in client, see: https://github.com/FAForever/downlords-faf-client/issues/1783 """ pass
Replace with full implementation when implemented in client, see: https://github.com/FAForever/downlords-faf-client/issues/1783
async def command_matchmaker_info(self, message)
-
Expand source code
async def command_matchmaker_info(self, message): await self.send({ "command": "matchmaker_info", "queues": [ queue.to_dict() for queue in self.ladder_service.queues.values() if queue.is_running ] })
async def command_modvault(self, message)
-
Expand source code
async def command_modvault(self, message): type = message["type"] async with self._db.acquire() as conn: if type == "start": result = await conn.execute("SELECT uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon FROM table_mod ORDER BY likes DESC LIMIT 100") for row in result: uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon = (row[i] for i in range(12)) try: link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) thumbstr = "" if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], comments=[], description=description, played=played, likes=likes, downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, ui=ui) await self.send(out) except Exception: self._logger.error(f"Error handling table_mod row (uid: {uid})", exc_info=True) elif type == "like": canLike = True uid = message["uid"] result = await conn.execute( "SELECT uid, name, version, author, ui, date, downloads, " "likes, played, description, filename, icon, likers FROM " "`table_mod` WHERE uid = :uid LIMIT 1", uid=uid ) row = result.fetchone() uid, name, version, author, ui, date, downloads, likes, played, description, filename, icon, likerList = (row[i] for i in range(13)) link = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/" + filename) thumbstr = "" if icon: thumbstr = urllib.parse.urljoin(config.CONTENT_URL, "faf/vault/mods_thumbs/" + urllib.parse.quote(icon)) out = dict(command="modvault_info", thumbnail=thumbstr, link=link, bugreports=[], comments=[], description=description, played=played, likes=likes + 1, downloads=downloads, date=int(date.timestamp()), uid=uid, name=name, version=version, author=author, ui=ui) try: likers = json.loads(likerList) if self.player.id in likers: canLike = False else: likers.append(self.player.id) except Exception: likers = [] # TODO: Avoid sending all the mod info in the world just because we liked it? if canLike: await conn.execute( "UPDATE mod_stats s " "JOIN mod_version v ON v.mod_id = s.mod_id " "SET s.likes = s.likes + 1, likers=:l WHERE v.uid=:id", l=json.dumps(likers), id=uid ) await self.send(out) elif type == "download": uid = message["uid"] await conn.execute( "UPDATE mod_stats s " "JOIN mod_version v ON v.mod_id = s.mod_id " "SET downloads=downloads+1 WHERE v.uid = %s", uid) else: raise ValueError("invalid type argument")
async def command_ping(self, msg)
-
Expand source code
async def command_ping(self, msg): await self.send({"command": "pong"})
async def command_pong(self, msg)
-
Expand source code
async def command_pong(self, msg): pass
async def command_restore_game_session(self, message)
-
Expand source code
@ice_only @player_idle("reconnect to a game") async def command_restore_game_session(self, message): assert self.player is not None 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 no longer exists") return game: Game = self.game_service[game_id] if game.state not in (GameState.LOBBY, GameState.LIVE): # NOTE: Getting here is only possible if you join within the # 1 second window between the game ending and the game being removed # from the game service. await self.send_warning("The game you were connected to is no longer available") return if ( game.state is GameState.LIVE and self.player.id not in (player.id for player in game.players) ): await self.send_warning("You are not part of this game") return self._logger.info("Restoring game session of player %s to game %s", self.player, game) self.game_connection = GameConnection( database=self._db, game=game, player=self.player, protocol=self.protocol, player_service=self.player_service, games=self.game_service, state=GameConnectionState.CONNECTED_TO_HOST ) game.add_game_connection(self.game_connection) self.player.state = PlayerState.PLAYING self.player.game = game
async def command_set_party_factions(self, message)
-
Expand source code
async def command_set_party_factions(self, message): factions = set(Faction.from_value(v) for v in message["factions"]) if not factions: raise ClientError( "You must select at least one faction.", recoverable=True ) self.party_service.set_factions(self.player, list(factions))
-
Expand source code
async def command_social_add(self, message): if "friend" in message: status = "FRIEND" subject_id = message["friend"] player_attr = self.player.friends elif "foe" in message: status = "FOE" subject_id = message["foe"] player_attr = self.player.foes else: return if subject_id in player_attr: return async with self._db.acquire() as conn: await conn.execute(friends_and_foes.insert().values( user_id=self.player.id, status=status, subject_id=subject_id, )) game = self.player.game visibility_context_manager = contextlib.nullcontext() if game and game.host == self.player: # If the player is currently hosting a game, we need to make sure # that the visibility change is sent to the subject subject = self.player_service.get_player(subject_id) visibility_context_manager = self._write_visibility_change_context( game, subject, ) with visibility_context_manager: player_attr.add(subject_id)
-
Expand source code
async def command_social_remove(self, message): if "friend" in message: subject_id = message["friend"] player_attr = self.player.friends elif "foe" in message: subject_id = message["foe"] player_attr = self.player.foes else: await self.abort("No-op social_remove.") return async with self._db.acquire() as conn: await conn.execute(friends_and_foes.delete().where(and_( friends_and_foes.c.user_id == self.player.id, friends_and_foes.c.subject_id == subject_id ))) game = self.player.game visibility_context_manager = contextlib.nullcontext() if game and game.host == self.player: # If the player is currently hosting a game, we need to make sure # that the visibility change is sent to the subject subject = self.player_service.get_player(subject_id) visibility_context_manager = self._write_visibility_change_context( game, subject, ) with visibility_context_manager: player_attr.discard(subject_id)
async def ensure_authenticated(self, cmd)
-
Expand source code
async def ensure_authenticated(self, cmd): if not self._authenticated: if cmd not in ( "Bottleneck", # sent by the game during reconnect "ask_session", "auth", "create_account", "hello", "ping", "pong", ): metrics.unauth_messages.labels(cmd).inc() await self.abort(f"Message invalid for unauthenticated connection: {cmd}") return False return True
def get_user_identifier(self) ‑> str
-
Expand source code
def get_user_identifier(self) -> str: """For logging purposes""" if self.player: return self.player.login return str(self.session)
For logging purposes
def ice_only(func)
-
Expand source code
def ice_only(func): """ Ensures that a handler function is not invoked from a non ICE client. """ @wraps(func) async def wrapper(self, message): if self._attempted_connectivity_test: raise ClientError("Cannot join game. Please update your client to the newest version.") return await func(self, message) return wrapper
Ensures that a handler function is not invoked from a non ICE client.
async def kick(self)
-
Expand source code
async def kick(self): await self.send({ "command": "notice", "style": "kick", }) await self.abort()
async def launch_game(self,
game: Game,
is_host: bool = False,
options: GameLaunchOptions = GameLaunchOptions(mapname=None, team=None, faction=None, expected_players=None, map_position=None, game_options=None)) ‑> None-
Expand source code
async def launch_game( self, game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), ) -> None: if self.game_connection: await self.game_connection.abort("Player launched a new game") self.game_connection = None await self.send(self._prepare_launch_game( game, is_host=is_host, options=options ))
async def on_connection_lost(self)
-
Expand source code
async def on_connection_lost(self): async def nop(*args, **kwargs): return self.send = nop if self._timeout_task and not self._timeout_task.done(): self._timeout_task.cancel() if self.game_connection: self._logger.debug( "Lost lobby connection killing game connection for player %s", self.game_connection.player.id ) await self.game_connection.on_connection_lost()
async def on_connection_made(self,
protocol: Protocol,
peername: Address)-
Expand source code
async def on_connection_made(self, protocol: Protocol, peername: Address): self.protocol = protocol self.peer_address = peername self._timeout_task = asyncio.create_task(self.timeout_login()) metrics.server_connections.inc()
async def on_message_received(self, message)
-
Expand source code
async def on_message_received(self, message): """ Dispatches incoming messages """ self._logger.log(TRACE, "<< %s: %s", self.get_user_identifier(), message) try: cmd = message["command"] if not await self.ensure_authenticated(cmd): return target = message.get("target") if target == "game": if not self.game_connection: return await self.game_connection.handle_action(cmd, message.get("args", [])) return if target == "connectivity" and message.get("command") == "InitiateTest": self._attempted_connectivity_test = True raise ClientError("Your client version is no longer supported. Please update to the newest version: https://faforever.com") handler = getattr(self, f"command_{cmd}") await handler(message) except AuthenticationError as e: metrics.user_logins.labels("failure", e.method).inc() await self.send({ "command": "authentication_failed", "text": e.message }) except BanError as e: await self.send({ "command": "notice", "style": "error", "text": e.message() }) await self.abort(e.message()) except ClientError as e: self._logger.warning( "ClientError[%s]: %s", self.user_agent, e.message, ) await self.send({ "command": "notice", "style": "error", "text": e.message }) if not e.recoverable: await self.abort(e.message) except (KeyError, ValueError) as e: self._logger.exception(e) await self.abort(f"Garbage command: {message}") except ConnectionError as e: # Propagate connection errors to the ServerContext error handler. raise e except DisabledError: # TODO: Respond with correlation uid for original message await self.send({"command": "disabled", "request": cmd}) self._logger.info( "Ignoring disabled command for %s: %s", self.get_user_identifier(), cmd ) except OperationalError: # When the database goes down, SqlAlchemy will throw an OperationalError self._logger.error("Encountered OperationalError on message received. This could indicate DB is down.") await self.send({ "command": "notice", "style": "error", "text": "Unable to connect to database. Please try again later." }) # Make sure to abort here to avoid a thundering herd problem. await self.abort("Error connecting to database") except Exception as e: # pragma: no cover await self.send({"command": "invalid"}) self._logger.exception(e) await self.abort("Error processing command")
Dispatches incoming messages
async def on_player_login(self, player_id: int, username: str, unique_id: str, method: str)
-
Expand source code
async def on_player_login( self, player_id: int, username: str, unique_id: str, method: str ): conforms_policy = await self.check_policy_conformity( player_id, unique_id, self.session, # All players are required to have game ownership verified # so this is for informational purposes only ignore_result=True ) if not conforms_policy: return old_player = self.player_service.get_player(player_id) if old_player: self._logger.debug( "player %s already signed in: %s", player_id, old_player ) if old_player.lobby_connection is self: await self.send_warning( "You are already signed in from this location!" ) return elif old_player.lobby_connection is not None: with contextlib.suppress(DisconnectedError): old_player.lobby_connection.write_warning( "You have been signed out because you signed in " "elsewhere.", fatal=True, style="kick" ) self._logger.info( "Login from: %s(id=%s), using method '%s' for session %s", username, player_id, method, self.session ) metrics.user_logins.labels("success", method).inc() async with self._db.acquire() as conn: await conn.execute( t_login.update().where( t_login.c.id == player_id ).values( ip=self.peer_address.host, user_agent=self.user_agent, last_login=func.now() ) ) self.player = Player( login=username, session=self.session, player_id=player_id, lobby_connection=self, leaderboards=self.rating_service.leaderboards ) await self.player_service.fetch_player_data(self.player) self.player_service[self.player.id] = self.player self._authenticated = True # Country # ------- self.player.country = self.geoip_service.country(self.peer_address.host) # Send the player their own player info. await self.send({ "command": "welcome", "me": self.player.to_dict(), "current_time": datetime_now().isoformat(), # For backwards compatibility for old clients. For now. "id": self.player.id, "login": username }) # Tell player about everybody online. This must happen after "welcome". await self.send({ "command": "player_info", "players": [player.to_dict() for player in self.player_service] }) # Tell everyone else online about us. This must happen after all the player_info messages. # This ensures that no other client will perform an operation that interacts with the # incoming user, allowing the client to make useful assumptions: it can be certain it has # initialised its local player service before it is going to get messages that want to # query it. self.player_service.mark_dirty(self.player) friends = [] foes = [] async with self._db.acquire() as conn: result = await conn.execute( select( friends_and_foes.c.subject_id, friends_and_foes.c.status ).where( friends_and_foes.c.user_id == self.player.id ) ) for row in result: if row.status == "FRIEND": friends.append(row.subject_id) else: foes.append(row.subject_id) self.player.friends = set(friends) self.player.foes = set(foes) channels = [] if self.player.is_moderator(): channels.append("#moderators") if self.player.clan is not None: channels.append(f"#{self.player.clan}_clan") json_to_send = { "command": "social", "autojoin": channels, "channels": channels, "friends": friends, "foes": foes, "power": self.player.power() } await self.send(json_to_send) await self.send_game_list()
def player_idle(state_text)
-
Expand source code
def player_idle(state_text): """ Ensures that a handler function is not invoked unless the player state is IDLE. """ def decorator(func): @wraps(func) async def wrapper(self, message): if self.player.state != PlayerState.IDLE: raise ClientError( f"Can't {state_text} while in state " f"{self.player.state.name}", recoverable=True ) return await func(self, message) return wrapper return decorator
Ensures that a handler function is not invoked unless the player state is IDLE.
async def send(self, message)
-
Expand source code
async def send(self, message): """Send a message and wait for it to be sent.""" self.write(message) await self.protocol.drain()
Send a message and wait for it to be sent.
async def send_game_list(self)
-
Expand source code
async def send_game_list(self): await self.send({ "command": "game_info", "games": [ game.to_dict() for game in self.game_service.open_games if game.is_visible_to_player(self.player) ] })
async def send_updated_achievements(self, updated_achievements)
-
Expand source code
async def send_updated_achievements(self, updated_achievements): await self.send({ "command": "updated_achievements", "updated_achievements": updated_achievements })
async def send_warning(self, message: str, fatal: bool = False)
-
Expand source code
async def send_warning(self, message: str, fatal: bool = False): """ Display a warning message to the client # Params - `message`: Warning message to display - `fatal`: Whether or not the warning is fatal. If the client receives a fatal warning it should disconnect and not attempt to reconnect. """ await self.send({ "command": "notice", "style": "info" if not fatal else "error", "text": message }) if fatal: await self.abort(message)
Display a warning message to the client
Params
message
: Warning message to displayfatal
: Whether or not the warning is fatal. If the client receives a fatal warning it should disconnect and not attempt to reconnect.
async def timeout_login(self)
-
Expand source code
async def timeout_login(self): with contextlib.suppress(asyncio.CancelledError): await asyncio.sleep(config.LOGIN_TIMEOUT) if not self._authenticated: await self.abort("Client took too long to log in.")
def write(self, message)
-
Expand source code
def write(self, message): """Write a message into the send buffer.""" self._logger.log(TRACE, ">> %s: %s", self.get_user_identifier(), message) self.protocol.write_message(message)
Write a message into the send buffer.
def write_launch_game(self,
game: Game,
is_host: bool = False,
options: GameLaunchOptions = GameLaunchOptions(mapname=None, team=None, faction=None, expected_players=None, map_position=None, game_options=None)) ‑> None-
Expand source code
def write_launch_game( self, game: Game, is_host: bool = False, options: GameLaunchOptions = GameLaunchOptions(), ) -> None: if self.game_connection is not None: self._logger.warning( "%s launched a new game while old GameConnection was active", self.player ) self.game_connection = None self.write(self._prepare_launch_game( game, is_host=is_host, options=options ))
def write_warning(self, message: str, fatal: bool = False, style: str | None = None)
-
Expand source code
def write_warning( self, message: str, fatal: bool = False, style: Optional[str] = None ): """ Like `send_warning`, but does not await the data to be sent. """ self.write({ "command": "notice", "style": style or ("info" if not fatal else "error"), "text": message }) if fatal: asyncio.create_task(self.abort(message))
Like
send_warning
, but does not await the data to be sent.