"""cho: handle cho packets from the osu! client""" from __future__ import annotations import asyncio import hashlib import logging import re import struct import time from collections.abc import Callable from collections.abc import Mapping from datetime import date from datetime import datetime from pathlib import Path from typing import Literal from typing import TypedDict from zoneinfo import ZoneInfo import bcrypt import databases.core from fastapi import APIRouter from fastapi import Response from fastapi.param_functions import Header from fastapi.requests import Request from fastapi.responses import HTMLResponse import app.packets import app.settings import app.state import app.usecases.performance import app.utils from app import commands from app._typing import IPAddress from app.constants import regexes from app.constants.gamemodes import GameMode from app.constants.mods import SPEED_CHANGING_MODS from app.constants.mods import Mods from app.constants.privileges import ClanPrivileges from app.constants.privileges import ClientPrivileges from app.constants.privileges import Privileges from app.logging import Ansi from app.logging import get_timestamp from app.logging import log from app.logging import magnitude_fmt_time from app.objects.beatmap import Beatmap from app.objects.beatmap import ensure_osu_file_is_available from app.objects.channel import Channel from app.objects.match import MAX_MATCH_NAME_LENGTH from app.objects.match import Match from app.objects.match import MatchTeams from app.objects.match import MatchTeamTypes from app.objects.match import MatchWinConditions from app.objects.match import Slot from app.objects.match import SlotStatus from app.objects.player import Action from app.objects.player import ClientDetails from app.objects.player import OsuStream from app.objects.player import OsuVersion from app.objects.player import Player from app.objects.player import PresenceFilter from app.packets import BanchoPacketReader from app.packets import BasePacket from app.packets import ClientPackets from app.packets import LoginFailureReason from app.repositories import client_hashes as client_hashes_repo from app.repositories import ingame_logins as logins_repo from app.repositories import mail as mail_repo from app.repositories import users as users_repo from app.state import services from app.usecases.performance import ScoreParams OSU_API_V2_CHANGELOG_URL = "https://osu.ppy.sh/api/v2/changelog" BEATMAPS_PATH = Path.cwd() / ".data/osu" DISK_CHAT_LOG_FILE = ".data/logs/chat.log" BASE_DOMAIN = app.settings.DOMAIN # TODO: dear god NOW_PLAYING_RGX = re.compile( r"^\x01ACTION is (?:playing|editing|watching|listening to) " rf"\[https://osu\.(?:{re.escape(BASE_DOMAIN)}|ppy\.sh)/beatmapsets/(?P\d{{1,10}})#/?(?:osu|taiko|fruits|mania)?/(?P\d{{1,10}})/? .+\]" r"(?: <(?PTaiko|CatchTheBeat|osu!mania)>)?" r"(?P(?: (?:-|\+|~|\|)\w+(?:~|\|)?)+)?\x01$", ) FIRST_USER_ID = 3 router = APIRouter(tags=["Bancho API"]) @router.get("/") async def bancho_http_handler() -> Response: """Handle a request from a web browser.""" new_line = "\n" matches = [m for m in app.state.sessions.matches if m is not None] players = [p for p in app.state.sessions.players if not p.is_bot_client] packets = app.state.packets["all"] return HTMLResponse( f""" Running bancho.py v{app.settings.VERSION} {len(players)} online players {len(matches)} matches packets handled ({len(packets)}) {new_line.join([f"{packet.name} ({packet.value})" for packet in packets])} Source code """, ) @router.get("/online") async def bancho_view_online_users() -> Response: """see who's online""" new_line = "\n" players: list[Player] = [] bots: list[Player] = [] for p in app.state.sessions.players: if p.is_bot_client: bots.append(p) else: players.append(p) id_max_length = len(str(max(p.id for p in app.state.sessions.players))) return HTMLResponse( f""" back users: {new_line.join([f"({p.id:>{id_max_length}}): {p.safe_name}" for p in players])} bots: {new_line.join(f"({p.id:>{id_max_length}}): {p.safe_name}" for p in bots)} """, ) @router.get("/matches") async def bancho_view_matches() -> Response: """ongoing matches""" new_line = "\n" ON_GOING = "ongoing" IDLE = "idle" max_status_length = len(max(ON_GOING, IDLE)) BEATMAP = "beatmap" HOST = "host" max_properties_length = max(len(BEATMAP), len(HOST)) matches = [m for m in app.state.sessions.matches if m is not None] match_id_max_length = ( len(str(max(match.id for match in matches))) if len(matches) else 0 ) return HTMLResponse( f""" back matches: {new_line.join( f'''{(ON_GOING if m.in_progress else IDLE):<{max_status_length}} ({m.id:>{match_id_max_length}}): {m.name} -- ''' + f"{new_line}-- ".join([ f'{BEATMAP:<{max_properties_length}}: {m.map_name}', f'{HOST:<{max_properties_length}}: <{m.host.id}> {m.host.safe_name}' ]) for m in matches )} """, ) @router.post("/") async def bancho_handler( request: Request, osu_token: str | None = Header(None), user_agent: Literal["osu!"] = Header(...), ) -> Response: ip = app.state.services.ip_resolver.get_ip(request.headers) if osu_token is None: # the client is performing a login login_data = await handle_osu_login_request( request.headers, await request.body(), ip, ) return Response( content=login_data["response_body"], headers={"cho-token": login_data["osu_token"]}, ) # get the player from the specified osu token. player = app.state.sessions.players.get(token=osu_token) if not player: # chances are, we just restarted the server # tell their client to reconnect immediately. return Response( content=( app.packets.notification("Server has restarted.") + app.packets.restart_server(0) # ms until reconnection ), ) if player.restricted: # restricted users may only use certain packet handlers. packet_map = app.state.packets["restricted"] else: packet_map = app.state.packets["all"] # bancho connections can be comprised of multiple packets; # our reader is designed to iterate through them individually, # allowing logic to be implemented around the actual handler. # NOTE: any unhandled packets will be ignored internally. with memoryview(await request.body()) as body_view: for packet in BanchoPacketReader(body_view, packet_map): await packet.handle(player) player.last_recv_time = time.time() response_data = player.dequeue() return Response(content=response_data) """ Packet logic """ def register( packet: ClientPackets, restricted: bool = False, ) -> Callable[[type[BasePacket]], type[BasePacket]]: """Register a handler in `app.state.packets`.""" def wrapper(cls: type[BasePacket]) -> type[BasePacket]: app.state.packets["all"][packet] = cls if restricted: app.state.packets["restricted"][packet] = cls return cls return wrapper @register(ClientPackets.PING, restricted=True) class Ping(BasePacket): async def handle(self, player: Player) -> None: pass # ping be like @register(ClientPackets.CHANGE_ACTION, restricted=True) class ChangeAction(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.action = reader.read_u8() self.info_text = reader.read_string() self.map_md5 = reader.read_string() self.mods = reader.read_u32() self.mode = reader.read_u8() if self.mods & Mods.RELAX: if self.mode == 3: # rx!mania doesn't exist self.mods &= ~Mods.RELAX else: self.mode += 4 elif self.mods & Mods.AUTOPILOT: if self.mode in (1, 2, 3): # ap!catch, taiko and mania don't exist self.mods &= ~Mods.AUTOPILOT else: self.mode += 8 self.map_id = reader.read_i32() async def handle(self, player: Player) -> None: # update the user's status. player.status.action = Action(self.action) player.status.info_text = self.info_text player.status.map_md5 = self.map_md5 player.status.mods = Mods(self.mods) player.status.mode = GameMode(self.mode) player.status.map_id = self.map_id # broadcast it to all online players. if not player.restricted: app.state.sessions.players.enqueue(app.packets.user_stats(player)) IGNORED_CHANNELS = ["#highlight", "#userlog"] @register(ClientPackets.SEND_PUBLIC_MESSAGE) class SendMessage(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.msg = reader.read_message() async def handle(self, player: Player) -> None: if player.silenced: log(f"{player} sent a message while silenced.", Ansi.LYELLOW) return # remove leading/trailing whitespace msg = self.msg.text.strip() if not msg: return recipient = self.msg.recipient if recipient in IGNORED_CHANNELS: return elif recipient == "#spectator": if player.spectating: # we are spectating someone spec_id = player.spectating.id elif player.spectators: # we are being spectated spec_id = player.id else: return t_chan = app.state.sessions.channels.get_by_name(f"#spec_{spec_id}") elif recipient == "#multiplayer": if not player.match: # they're not in a match? return t_chan = player.match.chat else: t_chan = app.state.sessions.channels.get_by_name(recipient) if not t_chan: log(f"{player} wrote to non-existent {recipient}.", Ansi.LYELLOW) return if player not in t_chan: log(f"{player} wrote to {recipient} without being in it.") return if not t_chan.can_write(player.priv): log(f"{player} wrote to {recipient} with insufficient privileges.") return # limit message length to 2k chars # perhaps this could be dangerous with !py..? if len(msg) > 2000: msg = f"{msg[:2000]}... (truncated)" player.enqueue( app.packets.notification( "Your message was truncated\n(exceeded 2000 characters).", ), ) if msg.startswith(app.settings.COMMAND_PREFIX): cmd = await commands.process_commands(player, t_chan, msg) else: cmd = None if cmd: # a command was triggered. if not cmd["hidden"]: t_chan.send(msg, sender=player) if cmd["resp"] is not None: t_chan.send_bot(cmd["resp"]) else: staff = app.state.sessions.players.staff t_chan.send_selective( msg=msg, sender=player, recipients=staff - {player}, ) if cmd["resp"] is not None: t_chan.send_selective( msg=cmd["resp"], sender=app.state.sessions.bot, recipients=staff | {player}, ) else: # no commands were triggered # check if the user is /np'ing a map. # even though this is a public channel, # we'll update the player's last np stored. r_match = NOW_PLAYING_RGX.match(msg) if r_match: # the player is /np'ing a map. # save it to their player instance # so we can use this elsewhere. bmap = await Beatmap.from_bid(int(r_match["bid"])) if bmap: # parse mode_vn int from regex if r_match["mode_vn"] is not None: mode_vn = {"Taiko": 1, "CatchTheBeat": 2, "osu!mania": 3}[ r_match["mode_vn"] ] else: # use player mode if not specified mode_vn = player.status.mode.as_vanilla # parse the mods from regex mods = None if r_match["mods"] is not None: mods = Mods.from_np(r_match["mods"][1:], mode_vn) player.last_np = { "bmap": bmap, "mods": mods, "mode_vn": mode_vn, "timeout": time.time() + 300, # /np's last 5mins } else: # time out their previous /np player.last_np = None t_chan.send(msg, sender=player) player.update_latest_activity_soon() log(f"{player} @ {t_chan}: {msg}", Ansi.LCYAN) with open(DISK_CHAT_LOG_FILE, "a+") as f: f.write( f"[{get_timestamp(full=True, tz=ZoneInfo('GMT'))}] {player} @ {t_chan}: {msg}\n", ) @register(ClientPackets.LOGOUT, restricted=True) class Logout(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: reader.read_i32() # reserved async def handle(self, player: Player) -> None: if (time.time() - player.login_time) < 1: # osu! has a weird tendency to log out immediately after login. # i've tested the times and they're generally 300-800ms, so # we'll block any logout request within 1 second from login. return player.logout() player.update_latest_activity_soon() @register(ClientPackets.REQUEST_STATUS_UPDATE, restricted=True) class StatsUpdateRequest(BasePacket): async def handle(self, player: Player) -> None: player.enqueue(app.packets.user_stats(player)) # Some messages to send on welcome/restricted/etc. # TODO: these should probably be moved to the config. WELCOME_MSG = "\n".join( ( f"Welcome to {BASE_DOMAIN}.", "To see a list of commands, use !help.", "We have a public (Discord)[https://discord.gg/memorial]!", "Enjoy the server!", ), ) RESTRICTED_MSG = ( "Your account has been restricted! " "While restricted, you will be unable to interact with other players " "and your scores will only be visible to you and staff. " "If you believe this is a mistake, or have waited longer than 3 months, " "you can appeal in our Discord." ) WELCOME_NOTIFICATION = app.packets.notification( f"Welcome back! Memento Mori.", ) OFFLINE_NOTIFICATION = app.packets.notification( "The server is currently running in offline mode; " "some features will be unavailable.", ) class LoginResponse(TypedDict): osu_token: str response_body: bytes class LoginData(TypedDict): username: str password_md5: bytes osu_version: str utc_offset: int display_city: bool pm_private: bool osu_path_md5: str adapters_str: str adapters_md5: str uninstall_md5: str disk_signature_md5: str def parse_login_data(data: bytes) -> LoginData: """Parse data from the body of a login request.""" ( username, password_md5, remainder, ) = data.decode().split("\n", maxsplit=2) ( osu_version, utc_offset, display_city, client_hashes, pm_private, ) = remainder.split("|", maxsplit=4) ( osu_path_md5, adapters_str, adapters_md5, uninstall_md5, disk_signature_md5, ) = client_hashes[:-1].split(":", maxsplit=4) return { "username": username, "password_md5": password_md5.encode(), "osu_version": osu_version, "utc_offset": int(utc_offset), "display_city": display_city == "1", "pm_private": pm_private == "1", "osu_path_md5": osu_path_md5, "adapters_str": adapters_str, "adapters_md5": adapters_md5, "uninstall_md5": uninstall_md5, "disk_signature_md5": disk_signature_md5, } def parse_osu_version_string(osu_version_string: str) -> OsuVersion | None: match = regexes.OSU_VERSION.match(osu_version_string) if match is None: return None osu_version = OsuVersion( date=date( year=int(match["date"][0:4]), month=int(match["date"][4:6]), day=int(match["date"][6:8]), ), revision=int(match["revision"]) if match["revision"] else None, stream=OsuStream(match["stream"] or "stable"), ) return osu_version async def get_allowed_client_versions(osu_stream: OsuStream) -> set[date] | None: """ Return a list of acceptable client versions for the given stream. This is used to determine whether a client is too old to connect to the server. Returns None if the connection to the osu! api fails. """ osu_stream_str = osu_stream.value if osu_stream in (OsuStream.STABLE, OsuStream.BETA): osu_stream_str += "40" # i wonder why this exists # TODO: Reverse the logic of whatever this is response = await services.http_client.get( OSU_API_V2_CHANGELOG_URL, params={"stream": osu_stream_str}, ) if not response.is_success: return None allowed_client_versions: set[date] = set() for build in response.json()["builds"]: version = date( int(build["version"][0:4]), int(build["version"][4:6]), int(build["version"][6:8]), ) allowed_client_versions.add(version) if any(entry["major"] for entry in build["changelog_entries"]): # this build is a major iteration to the client # don't allow anything older than this break return allowed_client_versions def parse_adapters_string(adapters_string: str) -> tuple[list[str], bool]: running_under_wine = adapters_string == "runningunderwine" adapters = adapters_string[:-1].split(".") return adapters, running_under_wine async def authenticate( username: str, untrusted_password: bytes, ) -> users_repo.User | None: user_info = await users_repo.fetch_one( name=username, fetch_all_fields=True, ) if user_info is None: return None trusted_hashword = user_info["pw_bcrypt"].encode() # in-memory bcrypt lookup cache for performance if trusted_hashword in app.state.cache.bcrypt: # ~0.01 ms if untrusted_password != app.state.cache.bcrypt[trusted_hashword]: return None else: # ~200ms if not bcrypt.checkpw(untrusted_password, trusted_hashword): return None app.state.cache.bcrypt[trusted_hashword] = untrusted_password return user_info async def handle_osu_login_request( headers: Mapping[str, str], body: bytes, ip: IPAddress, ) -> LoginResponse: """\ Login has no specific packet, but happens when the osu! client sends a request without an 'osu-token' header. Request format: username\npasswd_md5\nosu_version|utc_offset|display_city|client_hashes|pm_private\n Response format: Packet 5 (userid), with ID: -1: authentication failed -2: old client -3: banned -4: banned -5: error occurred -6: needs supporter -7: password reset -8: requires verification other: valid id, logged in """ # parse login data login_data = parse_login_data(body) # perform some validation & further parsing on the data osu_version = parse_osu_version_string(login_data["osu_version"]) if osu_version is None: return { "osu_token": "invalid-request", "response_body": ( app.packets.login_reply(LoginFailureReason.AUTHENTICATION_FAILED) + app.packets.notification("Please restart your osu! and try again.") ), } if app.settings.DISALLOW_OLD_CLIENTS: allowed_client_versions = await get_allowed_client_versions( osu_version.stream, ) # in the case where the osu! api fails, we'll allow the client to connect if ( allowed_client_versions is not None and osu_version.date not in allowed_client_versions ): return { "osu_token": "client-too-old", "response_body": ( app.packets.version_update() + app.packets.login_reply(LoginFailureReason.OLD_CLIENT) ), } adapters, running_under_wine = parse_adapters_string(login_data["adapters_str"]) if not (running_under_wine or any(adapters)): return { "osu_token": "empty-adapters", "response_body": ( app.packets.login_reply(LoginFailureReason.AUTHENTICATION_FAILED) + app.packets.notification("Please restart your osu! and try again.") ), } ## parsing successful login_time = time.time() # disallow multiple sessions from a single user # with the exception of tourney spectator clients player = app.state.sessions.players.get(name=login_data["username"]) if player and osu_version.stream != "tourney": # check if the existing session is still active if (login_time - player.last_recv_time) < 10: return { "osu_token": "user-already-logged-in", "response_body": ( app.packets.login_reply(LoginFailureReason.AUTHENTICATION_FAILED) + app.packets.notification("User already logged in.") ), } else: # session is not active; replace it player.logout() del player user_info = await authenticate(login_data["username"], login_data["password_md5"]) if user_info is None: return { "osu_token": "incorrect-credentials", "response_body": ( app.packets.notification(f"{BASE_DOMAIN}: Incorrect credentials") + app.packets.login_reply(LoginFailureReason.AUTHENTICATION_FAILED) ), } if osu_version.stream is OsuStream.TOURNEY and not ( user_info["priv"] & Privileges.DONATOR and user_info["priv"] & Privileges.UNRESTRICTED ): # trying to use tourney client with insufficient privileges. return { "osu_token": "no", "response_body": app.packets.login_reply( LoginFailureReason.AUTHENTICATION_FAILED, ), } """ login credentials verified """ await logins_repo.create( user_id=user_info["id"], ip=str(ip), osu_ver=osu_version.date, osu_stream=osu_version.stream, ) await client_hashes_repo.create( userid=user_info["id"], osupath=login_data["osu_path_md5"], adapters=login_data["adapters_md5"], uninstall_id=login_data["uninstall_md5"], disk_serial=login_data["disk_signature_md5"], ) # TODO: store adapters individually # Some disk manufacturers set constant/shared ids for their products. # In these cases, there's not a whole lot we can do -- we'll allow them thru. INACTIONABLE_DISK_SIGNATURE_MD5S: list[str] = [ hashlib.md5(b"0").hexdigest(), # "0" is likely the most common variant ] if login_data["disk_signature_md5"] not in INACTIONABLE_DISK_SIGNATURE_MD5S: disk_signature_md5 = login_data["disk_signature_md5"] else: disk_signature_md5 = None hw_matches = await client_hashes_repo.fetch_any_hardware_matches_for_user( userid=user_info["id"], running_under_wine=running_under_wine, adapters=login_data["adapters_md5"], uninstall_id=login_data["uninstall_md5"], disk_serial=disk_signature_md5, ) if hw_matches: # we have other accounts with matching hashes if user_info["priv"] & Privileges.VERIFIED: # this is a normal, registered & verified player. # TODO: this user already has a registered hwid. # they may be multi-accounting; # there may be some desirable behavior to implement here in the future. ... else: # this player is not verified yet, this is their first # time connecting in-game and submitting their hwid set. # we will not allow any banned matches; if there are any, # then ask the user to contact staff and resolve manually. if not all( [hw_match["priv"] & Privileges.UNRESTRICTED for hw_match in hw_matches], ): return { "osu_token": "contact-staff", "response_body": ( app.packets.notification( "Please contact staff directly to create an account.", ) + app.packets.login_reply( LoginFailureReason.AUTHENTICATION_FAILED, ) ), } """ All checks passed, player is safe to login """ # get clan & clan priv if we're in a clan clan_id: int | None = None clan_priv: ClanPrivileges | None = None if user_info["clan_id"] != 0: clan_id = user_info["clan_id"] clan_priv = ClanPrivileges(user_info["clan_priv"]) db_country = user_info["country"] geoloc = await app.state.services.fetch_geoloc(ip, headers) if geoloc is None: return { "osu_token": "login-failed", "response_body": ( app.packets.notification( f"{BASE_DOMAIN}: Login failed. Please contact an admin.", ) + app.packets.login_reply(LoginFailureReason.AUTHENTICATION_FAILED) ), } if db_country == "xx": # bugfix for old bancho.py versions when # country wasn't stored on registration. log(f"Fixing {login_data['username']}'s country.", Ansi.LGREEN) await users_repo.partial_update( id=user_info["id"], country=geoloc["country"]["acronym"], ) client_details = ClientDetails( osu_version=osu_version, osu_path_md5=login_data["osu_path_md5"], adapters_md5=login_data["adapters_md5"], uninstall_md5=login_data["uninstall_md5"], disk_signature_md5=login_data["disk_signature_md5"], adapters=adapters, ip=ip, ) player = Player( id=user_info["id"], name=user_info["name"], priv=Privileges(user_info["priv"]), pw_bcrypt=user_info["pw_bcrypt"].encode(), token=Player.generate_token(), clan_id=clan_id, clan_priv=clan_priv, geoloc=geoloc, utc_offset=login_data["utc_offset"], pm_private=login_data["pm_private"], silence_end=user_info["silence_end"], donor_end=user_info["donor_end"], client_details=client_details, login_time=login_time, is_tourney_client=osu_version.stream == "tourney", api_key=user_info["api_key"], ) data = bytearray(app.packets.protocol_version(19)) data += app.packets.login_reply(player.id) # *real* client privileges are sent with this packet, # then the user's apparent privileges are sent in the # userPresence packets to other players. we'll send # supporter along with the user's privileges here, # but not in userPresence (so that only donators # show up with the yellow name in-game, but everyone # gets osu!direct & other in-game perks). data += app.packets.bancho_privileges( player.bancho_priv | ClientPrivileges.SUPPORTER, ) data += WELCOME_NOTIFICATION # send all appropriate channel info to our player. # the osu! client will attempt to join the channels. for channel in app.state.sessions.channels: if ( not channel.auto_join or not channel.can_read(player.priv) or channel._name == "#lobby" # (can't be in mp lobby @ login) ): continue # send chan info to all players who can see # the channel (to update their playercounts) chan_info_packet = app.packets.channel_info( channel._name, channel.topic, len(channel.players), ) data += chan_info_packet for o in app.state.sessions.players: if channel.can_read(o.priv): o.enqueue(chan_info_packet) # tells osu! to reorder channels based on config. data += app.packets.channel_info_end() # fetch some of the player's # information from sql to be cached. await player.stats_from_sql_full() await player.relationships_from_sql() # TODO: fetch player.recent_scores from sql data += app.packets.main_menu_icon( icon_url=app.settings.MENU_ICON_URL, onclick_url=app.settings.MENU_ONCLICK_URL, ) data += app.packets.friends_list(player.friends) data += app.packets.silence_end(player.remaining_silence) # update our new player's stats, and broadcast them. user_data = app.packets.user_presence(player) + app.packets.user_stats(player) data += user_data if not player.restricted: # player is unrestricted, two way data for o in app.state.sessions.players: # enqueue us to them o.enqueue(user_data) # enqueue them to us. if not o.restricted: if o is app.state.sessions.bot: # optimization for bot since it's # the most frequently requested user data += app.packets.bot_presence(o) data += app.packets.bot_stats(o) else: data += app.packets.user_presence(o) data += app.packets.user_stats(o) # the player may have been sent mail while offline, # enqueue any messages from their respective authors. mail_rows = await mail_repo.fetch_all_mail_to_user( user_id=player.id, read=False, ) if mail_rows: sent_to: set[int] = set() for msg in mail_rows: # Add "Unread messages" header as the first message # for any given sender, to make it clear that the # messages are coming from the mail system. if msg["from_id"] not in sent_to: data += app.packets.send_message( sender=msg["from_name"], msg="Unread messages", recipient=msg["to_name"], sender_id=msg["from_id"], ) sent_to.add(msg["from_id"]) msg_time = datetime.fromtimestamp(msg["time"]) data += app.packets.send_message( sender=msg["from_name"], msg=f'[{msg_time:%a %b %d @ %H:%M%p}] {msg["msg"]}', recipient=msg["to_name"], sender_id=msg["from_id"], ) if not player.priv & Privileges.VERIFIED: # this is the player's first login, verify their # account & send info about the server/its usage. await player.add_privs(Privileges.VERIFIED) if player.id == FIRST_USER_ID: # this is the first player registering on # the server, grant them full privileges. await player.add_privs( Privileges.STAFF | Privileges.NOMINATOR | Privileges.WHITELISTED | Privileges.TOURNEY_MANAGER | Privileges.DONATOR | Privileges.ALUMNI, ) data += app.packets.send_message( sender=app.state.sessions.bot.name, msg=WELCOME_MSG, recipient=player.name, sender_id=app.state.sessions.bot.id, ) else: # player is restricted, one way data for o in app.state.sessions.players.unrestricted: # enqueue them to us. if o is app.state.sessions.bot: # optimization for bot since it's # the most frequently requested user data += app.packets.bot_presence(o) data += app.packets.bot_stats(o) else: data += app.packets.user_presence(o) data += app.packets.user_stats(o) data += app.packets.account_restricted() data += app.packets.send_message( sender=app.state.sessions.bot.name, msg=RESTRICTED_MSG, recipient=player.name, sender_id=app.state.sessions.bot.id, ) # add `p` to the global player list, # making them officially logged in. app.state.sessions.players.append(player) if app.state.services.datadog: if not player.restricted: app.state.services.datadog.increment("bancho.online_players") time_taken = time.time() - login_time app.state.services.datadog.histogram("bancho.login_time", time_taken) user_os = "unix (wine)" if running_under_wine else "win32" country_code = player.geoloc["country"]["acronym"].upper() log( f"{player} logged in from {country_code} using {login_data['osu_version']} on {user_os}", Ansi.LCYAN, ) player.update_latest_activity_soon() return {"osu_token": player.token, "response_body": bytes(data)} @register(ClientPackets.START_SPECTATING) class StartSpectating(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.target_id = reader.read_i32() async def handle(self, player: Player) -> None: new_host = app.state.sessions.players.get(id=self.target_id) if not new_host: log( f"{player} tried to spectate nonexistant id {self.target_id}.", Ansi.LYELLOW, ) return current_host = player.spectating if current_host: if current_host == new_host: # host hasn't changed, they didn't have # the map but have downloaded it. if not player.stealth: # NOTE: `player` would have already received the other # fellow spectators, so no need to resend them. new_host.enqueue(app.packets.spectator_joined(player.id)) player_joined = app.packets.fellow_spectator_joined(player.id) for spec in new_host.spectators: if spec is not player: spec.enqueue(player_joined) return current_host.remove_spectator(player) new_host.add_spectator(player) @register(ClientPackets.STOP_SPECTATING) class StopSpectating(BasePacket): async def handle(self, player: Player) -> None: host = player.spectating if not host: log(f"{player} tried to stop spectating when they're not..?", Ansi.LRED) return host.remove_spectator(player) @register(ClientPackets.SPECTATE_FRAMES) class SpectateFrames(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.frame_bundle = reader.read_replayframe_bundle() async def handle(self, player: Player) -> None: # ANTICHEAT: TODO: perform validations on the parsed frame bundle # to ensure it's not being tamperated with or weaponized. # This could improve our detection of timewarp. # NOTE: this is given a fastpath here for efficiency due to the # sheer rate of usage of these packets in spectator mode. # data = app.packets.spectateFrames(self.frame_bundle.raw_data) data = ( struct.pack(" None: if not player.spectating: log(f"{player} sent can't spectate while not spectating?", Ansi.LRED) return if not player.stealth: data = app.packets.spectator_cant_spectate(player.id) host = player.spectating host.enqueue(data) for t in host.spectators: t.enqueue(data) @register(ClientPackets.SEND_PRIVATE_MESSAGE) class SendPrivateMessage(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.msg = reader.read_message() async def handle(self, player: Player) -> None: if player.silenced: if app.settings.DEBUG: log(f"{player} tried to send a DM while silenced.", Ansi.LYELLOW) return # remove leading/trailing whitespace msg = self.msg.text.strip() if not msg: return target_name = self.msg.recipient # allow this to get from sql - players can receive # messages offline, due to the mail system. B) target = await app.state.sessions.players.from_cache_or_sql(name=target_name) if not target: if app.settings.DEBUG: log( f"{player} tried to write to non-existent user {target_name}.", Ansi.LYELLOW, ) return if player.id in target.blocks: player.enqueue(app.packets.user_dm_blocked(target_name)) if app.settings.DEBUG: log(f"{player} tried to message {target}, but they have them blocked.") return if target.pm_private and player.id not in target.friends: player.enqueue(app.packets.user_dm_blocked(target_name)) if app.settings.DEBUG: log(f"{player} tried to message {target}, but they are blocking dms.") return if target.silenced: # if target is silenced, inform player. player.enqueue(app.packets.target_silenced(target_name)) if app.settings.DEBUG: log(f"{player} tried to message {target}, but they are silenced.") return # limit message length to 2k chars # perhaps this could be dangerous with !py..? if len(msg) > 2000: msg = f"{msg[:2000]}... (truncated)" player.enqueue( app.packets.notification( "Your message was truncated\n(exceeded 2000 characters).", ), ) if target.status.action == Action.Afk and target.away_msg: # send away message if target is afk and has one set. player.send(target.away_msg, sender=target) if target is not app.state.sessions.bot: # target is not bot, send the message normally if online if target.is_online: target.send(msg, sender=player) else: # inform user they're offline, but # will receive the mail @ next login. player.enqueue( app.packets.notification( f"{target.name} is currently offline, but will " "receive your messsage on their next login.", ), ) # insert mail into db, marked as unread. await mail_repo.create( from_id=player.id, to_id=target.id, msg=msg, ) else: # messaging the bot, check for commands & /np. if msg.startswith(app.settings.COMMAND_PREFIX): cmd = await commands.process_commands(player, target, msg) else: cmd = None if cmd: # command triggered, send response if any. if cmd["resp"] is not None: player.send(cmd["resp"], sender=target) else: # no commands triggered. r_match = NOW_PLAYING_RGX.match(msg) if r_match: # user is /np'ing a map. # save it to their player instance # so we can use this elsewhere. bmap = await Beatmap.from_bid(int(r_match["bid"])) if bmap: # parse mode_vn int from regex if r_match["mode_vn"] is not None: mode_vn = {"Taiko": 1, "CatchTheBeat": 2, "osu!mania": 3}[ r_match["mode_vn"] ] else: # use player mode if not specified mode_vn = player.status.mode.as_vanilla # parse the mods from regex mods = None if r_match["mods"] is not None: mods = Mods.from_np(r_match["mods"][1:], mode_vn) player.last_np = { "bmap": bmap, "mode_vn": mode_vn, "mods": mods, "timeout": time.time() + 300, # /np's last 5mins } # calculate generic pp values from their /np osu_file_available = await ensure_osu_file_is_available( bmap.id, expected_md5=bmap.md5, ) if not osu_file_available: resp_msg = ( "Mapfile could not be found; " "this incident has been reported." ) else: # calculate pp for common generic values pp_calc_st = time.time_ns() mods = None if r_match["mods"] is not None: # [1:] to remove leading whitespace mods_str = r_match["mods"][1:] mods = Mods.from_np(mods_str, mode_vn) scores = [ ScoreParams( mode=mode_vn, mods=int(mods) if mods else None, acc=acc, ) for acc in app.settings.PP_CACHED_ACCURACIES ] results = app.usecases.performance.calculate_performances( osu_file_path=str(BEATMAPS_PATH / f"{bmap.id}.osu"), scores=scores, ) resp_msg = " | ".join( f"{acc}%: {result['performance']['pp']:,.2f}pp" for acc, result in zip( app.settings.PP_CACHED_ACCURACIES, results, ) ) elapsed = time.time_ns() - pp_calc_st resp_msg += f" | Elapsed: {magnitude_fmt_time(elapsed)}" else: resp_msg = "Could not find map." # time out their previous /np player.last_np = None player.send(resp_msg, sender=target) player.update_latest_activity_soon() log(f"{player} @ {target}: {msg}", Ansi.LCYAN) with open(DISK_CHAT_LOG_FILE, "a+") as f: f.write( f"[{get_timestamp(full=True, tz=ZoneInfo('GMT'))}] {player} @ {target}: {msg}\n", ) @register(ClientPackets.PART_LOBBY) class LobbyPart(BasePacket): async def handle(self, player: Player) -> None: player.in_lobby = False @register(ClientPackets.JOIN_LOBBY) class LobbyJoin(BasePacket): async def handle(self, player: Player) -> None: player.in_lobby = True for match in app.state.sessions.matches: if match is not None: try: player.enqueue(app.packets.new_match(match)) except ValueError: log( f"Failed to send match {match.id} to player joining lobby; likely due to missing host", Ansi.LYELLOW, ) stacktrace = app.utils.get_appropriate_stacktrace() await app.state.services.log_strange_occurrence(stacktrace) continue def validate_match_data( untrusted_match_data: app.packets.MultiplayerMatch, expected_host_id: int, ) -> bool: return all( ( untrusted_match_data.host_id == expected_host_id, len(untrusted_match_data.name) <= MAX_MATCH_NAME_LENGTH, ), ) @register(ClientPackets.CREATE_MATCH) class MatchCreate(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_data = reader.read_match() async def handle(self, player: Player) -> None: if not validate_match_data(self.match_data, expected_host_id=player.id): log(f"{player} tried to create a match with invalid data.", Ansi.LYELLOW) return if player.restricted: player.enqueue( app.packets.match_join_fail() + app.packets.notification( "Multiplayer is not available while restricted.", ), ) return if player.silenced: player.enqueue( app.packets.match_join_fail() + app.packets.notification( "Multiplayer is not available while silenced.", ), ) return match_id = app.state.sessions.matches.get_free() if match_id is None: # failed to create match (match slots full). player.send_bot("Failed to create match (no slots available).") player.enqueue(app.packets.match_join_fail()) return # create the channel and add it # to the global channel list as # an instanced channel. chat_channel = Channel( name=f"#multi_{match_id}", topic=f"MID {match_id}'s multiplayer channel.", auto_join=False, instance=True, ) match = Match( id=match_id, name=self.match_data.name, password=self.match_data.passwd.removesuffix("//private"), has_public_history=not self.match_data.passwd.endswith("//private"), map_name=self.match_data.map_name, map_id=self.match_data.map_id, map_md5=self.match_data.map_md5, host_id=self.match_data.host_id, mode=GameMode(self.match_data.mode), mods=Mods(self.match_data.mods), win_condition=MatchWinConditions(self.match_data.win_condition), team_type=MatchTeamTypes(self.match_data.team_type), freemods=bool(self.match_data.freemods), seed=self.match_data.seed, chat_channel=chat_channel, ) app.state.sessions.matches[match_id] = match app.state.sessions.channels.append(chat_channel) match.chat = chat_channel player.update_latest_activity_soon() player.join_match(match, self.match_data.passwd) match.chat.send_bot(f"Match created by {player.name}.") log(f"{player} created a new multiplayer match.") @register(ClientPackets.JOIN_MATCH) class MatchJoin(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_id = reader.read_i32() self.match_passwd = reader.read_string() async def handle(self, player: Player) -> None: match = app.state.sessions.matches[self.match_id] if not match: log(f"{player} tried to join a non-existant mp lobby?") player.enqueue(app.packets.match_join_fail()) return if player.restricted: player.enqueue( app.packets.match_join_fail() + app.packets.notification( "Multiplayer is not available while restricted.", ), ) return if player.silenced: player.enqueue( app.packets.match_join_fail() + app.packets.notification( "Multiplayer is not available while silenced.", ), ) return player.update_latest_activity_soon() player.join_match(match, self.match_passwd) @register(ClientPackets.PART_MATCH) class MatchPart(BasePacket): async def handle(self, player: Player) -> None: player.update_latest_activity_soon() player.leave_match() @register(ClientPackets.MATCH_CHANGE_SLOT) class MatchChangeSlot(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.slot_id = reader.read_i32() async def handle(self, player: Player) -> None: if player.match is None: return # read new slot ID if not 0 <= self.slot_id < 16: return if player.match.slots[self.slot_id].status != SlotStatus.open: log(f"{player} tried to move into non-open slot.", Ansi.LYELLOW) return # swap with current slot. slot = player.match.get_slot(player) assert slot is not None player.match.slots[self.slot_id].copy_from(slot) slot.reset() player.match.enqueue_state() # technically not needed for host? @register(ClientPackets.MATCH_READY) class MatchReady(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.status = SlotStatus.ready player.match.enqueue_state(lobby=False) @register(ClientPackets.MATCH_LOCK) class MatchLock(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.slot_id = reader.read_i32() async def handle(self, player: Player) -> None: if player.match is None: return if player is not player.match.host: log(f"{player} attempted to lock match as non-host.", Ansi.LYELLOW) return # read new slot ID if not 0 <= self.slot_id < 16: return slot = player.match.slots[self.slot_id] if slot.status == SlotStatus.locked: slot.status = SlotStatus.open else: if slot.player is player.match.host: # don't allow the match host to kick # themselves by clicking their crown return if slot.player: # uggggggh i hate trusting the osu! client # man why is it designed like this # TODO: probably going to end up changing ... # slot.reset() slot.status = SlotStatus.locked player.match.enqueue_state() @register(ClientPackets.MATCH_CHANGE_SETTINGS) class MatchChangeSettings(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_data = reader.read_match() async def handle(self, player: Player) -> None: if not validate_match_data(self.match_data, expected_host_id=player.id): log( f"{player} tried to change match settings with invalid data.", Ansi.LYELLOW, ) return if player.match is None: return if player is not player.match.host: log(f"{player} attempted to change settings as non-host.", Ansi.LYELLOW) return if self.match_data.freemods != player.match.freemods: # freemods status has been changed. player.match.freemods = self.match_data.freemods if self.match_data.freemods: # match mods -> active slot mods. for slot in player.match.slots: if slot.player is not None: # the slot takes any non-speed # changing mods from the match. slot.mods = player.match.mods & ~SPEED_CHANGING_MODS # keep only speed-changing mods. player.match.mods &= SPEED_CHANGING_MODS else: # host mods -> match mods. host = player.match.get_host_slot() # should always exist assert host is not None # the match keeps any speed-changing mods, # and also takes any mods the host has enabled. player.match.mods &= SPEED_CHANGING_MODS player.match.mods |= host.mods for slot in player.match.slots: if slot.player is not None: slot.mods = Mods.NOMOD if self.match_data.map_id == -1: # map being changed, unready players. player.match.unready_players(expected=SlotStatus.ready) player.match.prev_map_id = player.match.map_id player.match.map_id = -1 player.match.map_md5 = "" player.match.map_name = "" elif player.match.map_id == -1: if player.match.prev_map_id != self.match_data.map_id: # new map has been chosen, send to match chat. map_url = ( f"https://osu.{app.settings.DOMAIN}/b/{self.match_data.map_id}" ) map_embed = f"[{map_url} {self.match_data.map_name}]" player.match.chat.send_bot(f"Selected: {map_embed}.") # use our serverside version if we have it, but # still allow for users to pick unknown maps. bmap = await Beatmap.from_md5(self.match_data.map_md5) if bmap: player.match.map_id = bmap.id player.match.map_md5 = bmap.md5 player.match.map_name = bmap.full_name player.match.mode = GameMode(player.match.host.status.mode.as_vanilla) else: player.match.map_id = self.match_data.map_id player.match.map_md5 = self.match_data.map_md5 player.match.map_name = self.match_data.map_name player.match.mode = GameMode(self.match_data.mode) if player.match.team_type != self.match_data.team_type: # if theres currently a scrim going on, only allow # team type to change by using the !mp teams command. if player.match.is_scrimming: _team = ("head-to-head", "tag-coop", "team-vs", "tag-team-vs")[ self.match_data.team_type ] msg = ( "Changing team type while scrimming will reset " "the overall score - to do so, please use the " f"!mp teams {_team} command." ) player.match.chat.send_bot(msg) else: # find the new appropriate default team. # defaults are (ffa: neutral, teams: red). if self.match_data.team_type in ( MatchTeamTypes.head_to_head, MatchTeamTypes.tag_coop, ): new_t = MatchTeams.neutral else: new_t = MatchTeams.red # change each active slots team to # fit the correspoding team type. for slot in player.match.slots: if slot.player is not None: slot.team = new_t # change the matches'. player.match.team_type = MatchTeamTypes(self.match_data.team_type) if player.match.win_condition != self.match_data.win_condition: # win condition changing; if `use_pp_scoring` # is enabled, disable it. always use new cond. if player.match.use_pp_scoring: player.match.use_pp_scoring = False player.match.win_condition = MatchWinConditions( self.match_data.win_condition, ) player.match.name = self.match_data.name player.match.enqueue_state() @register(ClientPackets.MATCH_START) class MatchStart(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return if player is not player.match.host: log(f"{player} attempted to start match as non-host.", Ansi.LYELLOW) return player.match.start() @register(ClientPackets.MATCH_SCORE_UPDATE) class MatchScoreUpdate(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.play_data = reader.read_raw() async def handle(self, player: Player) -> None: # this runs very frequently in matches, # so it's written to run pretty quick. if player.match is None: return slot_id = player.match.get_slot_id(player) assert slot_id is not None # if scorev2 is enabled, read an extra 8 bytes. buf = bytearray(b"0\x00\x00") buf += len(self.play_data).to_bytes(4, "little") buf += self.play_data buf[11] = slot_id player.match.enqueue(bytes(buf), lobby=False) @register(ClientPackets.MATCH_COMPLETE) class MatchComplete(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.status = SlotStatus.complete # check if there are any players that haven't finished. if any([s.status == SlotStatus.playing for s in player.match.slots]): return # find any players just sitting in the multi room # that have not been playing the map; they don't # need to know all the players have completed, only # the ones who are playing (just new match info). not_playing = [ s.player.id for s in player.match.slots if s.player is not None and s.status != SlotStatus.complete ] was_playing = [ s for s in player.match.slots if s.player and s.player.id not in not_playing ] player.match.unready_players(expected=SlotStatus.complete) player.match.reset_players_loaded_status() player.match.in_progress = False player.match.enqueue( app.packets.match_complete(), lobby=False, immune=not_playing, ) player.match.enqueue_state() if player.match.is_scrimming: # determine winner, update match points & inform players. asyncio.create_task(player.match.update_matchpoints(was_playing)) @register(ClientPackets.MATCH_CHANGE_MODS) class MatchChangeMods(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.mods = reader.read_i32() async def handle(self, player: Player) -> None: if player.match is None: return if player.match.freemods: if player is player.match.host: # allow host to set speed-changing mods. player.match.mods = Mods(self.mods & SPEED_CHANGING_MODS) # set slot mods slot = player.match.get_slot(player) assert slot is not None slot.mods = Mods(self.mods & ~SPEED_CHANGING_MODS) else: if player is not player.match.host: log(f"{player} attempted to change mods as non-host.", Ansi.LYELLOW) return # not freemods, set match mods. player.match.mods = Mods(self.mods) player.match.enqueue_state() def is_playing(slot: Slot) -> bool: return slot.status == SlotStatus.playing and not slot.loaded @register(ClientPackets.MATCH_LOAD_COMPLETE) class MatchLoadComplete(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return # our player has loaded in and is ready to play. slot = player.match.get_slot(player) assert slot is not None slot.loaded = True # check if all players are loaded, # if so, tell all players to begin. if not any(map(is_playing, player.match.slots)): player.match.enqueue(app.packets.match_all_players_loaded(), lobby=False) @register(ClientPackets.MATCH_NO_BEATMAP) class MatchNoBeatmap(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.status = SlotStatus.no_map player.match.enqueue_state(lobby=False) @register(ClientPackets.MATCH_NOT_READY) class MatchNotReady(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.status = SlotStatus.not_ready player.match.enqueue_state(lobby=False) @register(ClientPackets.MATCH_FAILED) class MatchFailed(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return # find the player's slot id, and enqueue that # they've failed to all other players in the match. slot_id = player.match.get_slot_id(player) assert slot_id is not None player.match.enqueue(app.packets.match_player_failed(slot_id), lobby=False) @register(ClientPackets.MATCH_HAS_BEATMAP) class MatchHasBeatmap(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.status = SlotStatus.not_ready player.match.enqueue_state(lobby=False) @register(ClientPackets.MATCH_SKIP_REQUEST) class MatchSkipRequest(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return slot = player.match.get_slot(player) assert slot is not None slot.skipped = True player.match.enqueue(app.packets.match_player_skipped(player.id)) for slot in player.match.slots: if slot.status == SlotStatus.playing and not slot.skipped: return # all users have skipped, enqueue a skip. player.match.enqueue(app.packets.match_skip(), lobby=False) @register(ClientPackets.CHANNEL_JOIN, restricted=True) class ChannelJoin(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.name = reader.read_string() async def handle(self, player: Player) -> None: if self.name in IGNORED_CHANNELS: return channel = app.state.sessions.channels.get_by_name(self.name) if not channel or not player.join_channel(channel): log(f"{player} failed to join {self.name}.", Ansi.LYELLOW) return @register(ClientPackets.MATCH_TRANSFER_HOST) class MatchTransferHost(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.slot_id = reader.read_i32() async def handle(self, player: Player) -> None: if player.match is None: return if player is not player.match.host: log(f"{player} attempted to transfer host as non-host.", Ansi.LYELLOW) return # read new slot ID if not 0 <= self.slot_id < 16: return target = player.match.slots[self.slot_id].player if not target: log(f"{player} tried to transfer host to an empty slot?") return player.match.host_id = target.id player.match.host.enqueue(app.packets.match_transfer_host()) player.match.enqueue_state() @register(ClientPackets.TOURNAMENT_MATCH_INFO_REQUEST) class TourneyMatchInfoRequest(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_id = reader.read_i32() async def handle(self, player: Player) -> None: if not 0 <= self.match_id < 64: return # invalid match id if not player.priv & Privileges.DONATOR: return # insufficient privs match = app.state.sessions.matches[self.match_id] if not match: return # match not found player.enqueue(app.packets.update_match(match, send_pw=False)) @register(ClientPackets.TOURNAMENT_JOIN_MATCH_CHANNEL) class TourneyMatchJoinChannel(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_id = reader.read_i32() async def handle(self, player: Player) -> None: if not 0 <= self.match_id < 64: return # invalid match id if not player.priv & Privileges.DONATOR: return # insufficient privs match = app.state.sessions.matches[self.match_id] if not match: return # match not found for slot in match.slots: if slot.player is not None: if player.id == slot.player.id: return # playing in the match # attempt to join match chan if player.join_channel(match.chat): match.tourney_clients.add(player.id) @register(ClientPackets.TOURNAMENT_LEAVE_MATCH_CHANNEL) class TourneyMatchLeaveChannel(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_id = reader.read_i32() async def handle(self, player: Player) -> None: if not 0 <= self.match_id < 64: return # invalid match id if not player.priv & Privileges.DONATOR: return # insufficient privs match = app.state.sessions.matches[self.match_id] if not (match and player.id in match.tourney_clients): return # match not found # attempt to join match chan player.leave_channel(match.chat) match.tourney_clients.remove(player.id) @register(ClientPackets.FRIEND_ADD) class FriendAdd(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.user_id = reader.read_i32() async def handle(self, player: Player) -> None: target = app.state.sessions.players.get(id=self.user_id) if not target: log(f"{player} tried to add a user who is not online! ({self.user_id})") return if target is app.state.sessions.bot: return if target.id in player.blocks: player.blocks.remove(target.id) player.update_latest_activity_soon() await player.add_friend(target) @register(ClientPackets.FRIEND_REMOVE) class FriendRemove(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.user_id = reader.read_i32() async def handle(self, player: Player) -> None: target = app.state.sessions.players.get(id=self.user_id) if not target: log(f"{player} tried to remove a user who is not online! ({self.user_id})") return if target is app.state.sessions.bot: return player.update_latest_activity_soon() await player.remove_friend(target) @register(ClientPackets.MATCH_CHANGE_TEAM) class MatchChangeTeam(BasePacket): async def handle(self, player: Player) -> None: if player.match is None: return # toggle team slot = player.match.get_slot(player) assert slot is not None if slot.team == MatchTeams.blue: slot.team = MatchTeams.red else: slot.team = MatchTeams.blue player.match.enqueue_state(lobby=False) @register(ClientPackets.CHANNEL_PART, restricted=True) class ChannelPart(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.name = reader.read_string() async def handle(self, player: Player) -> None: if self.name in IGNORED_CHANNELS: return channel = app.state.sessions.channels.get_by_name(self.name) if not channel: log(f"{player} failed to leave {self.name}.", Ansi.LYELLOW) return if player not in channel: # user not in chan return # leave the chan server-side. player.leave_channel(channel) @register(ClientPackets.RECEIVE_UPDATES, restricted=True) class ReceiveUpdates(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.value = reader.read_i32() async def handle(self, player: Player) -> None: if not 0 <= self.value < 3: log(f"{player} tried to set his presence filter to {self.value}?") return player.pres_filter = PresenceFilter(self.value) @register(ClientPackets.SET_AWAY_MESSAGE) class SetAwayMessage(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.msg = reader.read_message() async def handle(self, player: Player) -> None: player.away_msg = self.msg.text @register(ClientPackets.USER_STATS_REQUEST, restricted=True) class StatsRequest(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.user_ids = reader.read_i32_list_i16l() async def handle(self, player: Player) -> None: unrestrcted_ids = [p.id for p in app.state.sessions.players.unrestricted] is_online = lambda o: o in unrestrcted_ids and o != player.id for online in filter(is_online, self.user_ids): target = app.state.sessions.players.get(id=online) if target: if target is app.state.sessions.bot: # optimization for bot since it's # the most frequently requested user packet = app.packets.bot_stats(target) else: packet = app.packets.user_stats(target) player.enqueue(packet) @register(ClientPackets.MATCH_INVITE) class MatchInvite(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.user_id = reader.read_i32() async def handle(self, player: Player) -> None: if not player.match: return target = app.state.sessions.players.get(id=self.user_id) if not target: log(f"{player} tried to invite a user who is not online! ({self.user_id})") return if target is app.state.sessions.bot: player.send_bot("I'm too busy!") return target.enqueue(app.packets.match_invite(player, target.name)) player.update_latest_activity_soon() log(f"{player} invited {target} to their match.") @register(ClientPackets.MATCH_CHANGE_PASSWORD) class MatchChangePassword(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.match_data = reader.read_match() async def handle(self, player: Player) -> None: if not validate_match_data(self.match_data, expected_host_id=player.id): log( f"{player} tried to change match password with invalid data.", Ansi.LYELLOW, ) return if player.match is None: return if player is not player.match.host: log(f"{player} attempted to change pw as non-host.", Ansi.LYELLOW) return player.match.passwd = self.match_data.passwd player.match.enqueue_state() @register(ClientPackets.USER_PRESENCE_REQUEST) class UserPresenceRequest(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.user_ids = reader.read_i32_list_i16l() async def handle(self, player: Player) -> None: for pid in self.user_ids: target = app.state.sessions.players.get(id=pid) if target: if target is app.state.sessions.bot: # optimization for bot since it's # the most frequently requested user packet = app.packets.bot_presence(target) else: packet = app.packets.user_presence(target) player.enqueue(packet) @register(ClientPackets.USER_PRESENCE_REQUEST_ALL) class UserPresenceRequestAll(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.ingame_time = reader.read_i32() async def handle(self, player: Player) -> None: # NOTE: this packet is only used when there # are >256 players visible to the client. buffer = bytearray() for player in app.state.sessions.players.unrestricted: buffer += app.packets.user_presence(player) player.enqueue(bytes(buffer)) @register(ClientPackets.TOGGLE_BLOCK_NON_FRIEND_DMS) class ToggleBlockingDMs(BasePacket): def __init__(self, reader: BanchoPacketReader) -> None: self.value = reader.read_i32() async def handle(self, player: Player) -> None: player.pm_private = self.value == 1 player.update_latest_activity_soon()