mirror of
https://github.com/nihilvux/bancho.py.git
synced 2025-09-16 10:38:39 -07:00
2229 lines
74 KiB
Python
2229 lines
74 KiB
Python
"""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<sid>\d{{1,10}})#/?(?:osu|taiko|fruits|mania)?/(?P<bid>\d{{1,10}})/? .+\]"
|
|
r"(?: <(?P<mode_vn>Taiko|CatchTheBeat|osu!mania)>)?"
|
|
r"(?P<mods>(?: (?:-|\+|~|\|)\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"""
|
|
<!DOCTYPE html>
|
|
<body style="font-family: monospace; white-space: pre-wrap;">Running bancho.py v{app.settings.VERSION}
|
|
|
|
<a href="online">{len(players)} online players</a>
|
|
<a href="matches">{len(matches)} matches</a>
|
|
|
|
<b>packets handled ({len(packets)})</b>
|
|
{new_line.join([f"{packet.name} ({packet.value})" for packet in packets])}
|
|
|
|
<a href="https://github.com/twinkangelz/bancho.py">Source code</a>
|
|
</body>
|
|
</html>""",
|
|
)
|
|
|
|
|
|
@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"""
|
|
<!DOCTYPE html>
|
|
<body style="font-family: monospace; white-space: pre-wrap;"><a href="/">back</a>
|
|
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)}
|
|
</body>
|
|
</html>""",
|
|
)
|
|
|
|
|
|
@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"""
|
|
<!DOCTYPE html>
|
|
<body style="font-family: monospace; white-space: pre-wrap;"><a href="/">back</a>
|
|
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
|
|
)}
|
|
</body>
|
|
</html>""",
|
|
)
|
|
|
|
|
|
@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"If you ain't 22, I aint touching you. -Yuki, 2025",
|
|
)
|
|
|
|
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
|
|
|
|
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("<HxI", 15, len(self.frame_bundle.raw_data))
|
|
+ self.frame_bundle.raw_data
|
|
)
|
|
|
|
# enqueue the data
|
|
# to all spectators.
|
|
for spectator in player.spectators:
|
|
spectator.enqueue(data)
|
|
|
|
|
|
@register(ClientPackets.CANT_SPECTATE)
|
|
class CantSpectate(BasePacket):
|
|
async def handle(self, player: Player) -> 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()
|