from __future__ import annotations import importlib.metadata import os import pprint import random import secrets import signal import time import traceback import uuid from collections.abc import Awaitable from collections.abc import Callable from collections.abc import Mapping from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime from datetime import timedelta from functools import wraps from pathlib import Path from time import perf_counter_ns as clock_ns from typing import TYPE_CHECKING from typing import Any from typing import NamedTuple from typing import NoReturn from typing import Optional from typing import TypedDict from urllib.parse import urlparse import cpuinfo import psutil import timeago from pytimeparse.timeparse import timeparse import app.logging import app.packets import app.settings import app.state import app.usecases.performance import app.utils from app.constants import regexes from app.constants.gamemodes import GAMEMODE_REPR_LIST 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 Privileges from app.logging import Ansi from app.logging import log from app.objects.beatmap import Beatmap from app.objects.beatmap import RankedStatus from app.objects.beatmap import ensure_osu_file_is_available 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 SlotStatus from app.objects.player import Player from app.objects.score import SubmissionStatus from app.repositories import clans as clans_repo from app.repositories import logs as logs_repo from app.repositories import map_requests as map_requests_repo from app.repositories import maps as maps_repo from app.repositories import tourney_pool_maps as tourney_pool_maps_repo from app.repositories import tourney_pools as tourney_pools_repo from app.repositories import users as users_repo from app.usecases.performance import ScoreParams if TYPE_CHECKING: from app.objects.channel import Channel BEATMAPS_PATH = Path.cwd() / ".data/osu" @dataclass class Context: player: Player trigger: str args: Sequence[str] recipient: Channel | Player Callback = Callable[[Context], Awaitable[Optional[str]]] class Command(NamedTuple): triggers: list[str] callback: Callback priv: Privileges hidden: bool doc: str | None class CommandSet: def __init__(self, trigger: str, doc: str) -> None: self.trigger = trigger self.doc = doc self.commands: list[Command] = [] def add( self, priv: Privileges, aliases: list[str] = [], hidden: bool = False, ) -> Callable[[Callback], Callback]: def wrapper(f: Callback) -> Callback: self.commands.append( Command( # NOTE: this method assumes that functions without any # triggers will be named like '{self.trigger}_{trigger}'. triggers=( [f.__name__.removeprefix(f"{self.trigger}_").strip()] + aliases ), callback=f, priv=priv, hidden=hidden, doc=f.__doc__, ), ) return f return wrapper mp_commands = CommandSet("mp", "Multiplayer commands.") pool_commands = CommandSet("pool", "Mappool commands.") clan_commands = CommandSet("clan", "Clan commands.") regular_commands = [] command_sets = [ mp_commands, pool_commands, clan_commands, ] def command( priv: Privileges, aliases: list[str] = [], hidden: bool = False, ) -> Callable[[Callback], Callback]: def wrapper(f: Callback) -> Callback: regular_commands.append( Command( callback=f, priv=priv, hidden=hidden, triggers=[f.__name__.strip("_")] + aliases, doc=f.__doc__, ), ) return f return wrapper """ User commands # The commands below are not considered dangerous, # and are granted to any unbanned players. """ @command(Privileges.UNRESTRICTED, aliases=["", "h"], hidden=True) async def _help(ctx: Context) -> str | None: """Show all documented commands the player can access.""" prefix = app.settings.COMMAND_PREFIX l = ["Individual commands", "-----------"] for cmd in regular_commands: if not cmd.doc or ctx.player.priv & cmd.priv != cmd.priv: # no doc, or insufficient permissions. continue l.append(f"{prefix}{cmd.triggers[0]}: {cmd.doc}") l.append("") # newline l.extend(["Command sets", "-----------"]) for cmd_set in command_sets: l.append(f"{prefix}{cmd_set.trigger}: {cmd_set.doc}") return "\n".join(l) @command(Privileges.UNRESTRICTED) async def roll(ctx: Context) -> str | None: """Roll an n-sided die where n is the number you write (100 default).""" if ctx.args and ctx.args[0].isdecimal(): max_roll = min(int(ctx.args[0]), 0x7FFF) else: max_roll = 100 if max_roll == 0: return "Roll what?" points = random.randrange(0, max_roll) return f"{ctx.player.name} rolls {points} points!" @command(Privileges.UNRESTRICTED, hidden=True) async def block(ctx: Context) -> str | None: """Block another user from communicating with you.""" target = await app.state.sessions.players.from_cache_or_sql(name=" ".join(ctx.args)) if not target: return "User not found." if target is app.state.sessions.bot or target is ctx.player: return "What?" if target.id in ctx.player.blocks: return f"{target.name} already blocked!" if target.id in ctx.player.friends: ctx.player.friends.remove(target.id) await ctx.player.add_block(target) return f"Added {target.name} to blocked users." @command(Privileges.UNRESTRICTED, hidden=True) async def unblock(ctx: Context) -> str | None: """Unblock another user from communicating with you.""" target = await app.state.sessions.players.from_cache_or_sql(name=" ".join(ctx.args)) if not target: return "User not found." if target is app.state.sessions.bot or target is ctx.player: return "What?" if target.id not in ctx.player.blocks: return f"{target.name} not blocked!" await ctx.player.remove_block(target) return f"Removed {target.name} from blocked users." @command(Privileges.UNRESTRICTED) async def reconnect(ctx: Context) -> str | None: """Disconnect and reconnect a given player (or self) to the server.""" if ctx.args: # !reconnect if not ctx.player.priv & Privileges.ADMINISTRATOR: return None # requires admin target = app.state.sessions.players.get(name=" ".join(ctx.args)) if not target: return "Player not found" else: # !reconnect target = ctx.player target.logout() return None @command(Privileges.SUPPORTER) async def changename(ctx: Context) -> str | None: """Change your username.""" name = " ".join(ctx.args).strip() if not regexes.USERNAME.match(name): return "Must be 2-15 characters in length." if "_" in name and " " in name: return 'May contain "_" and " ", but not both.' if name in app.settings.DISALLOWED_NAMES: return "Disallowed username; pick another." if await users_repo.fetch_one(name=name): return "Username already taken by another player." # all checks passed, update their name await users_repo.partial_update(ctx.player.id, name=name) ctx.player.enqueue( app.packets.notification(f"Your username has been changed to {name}!"), ) ctx.player.logout() return None @command(Privileges.UNRESTRICTED, aliases=["bloodcat", "beatconnect", "chimu", "q"]) async def maplink(ctx: Context) -> str | None: """Return a download link to the user's current map (situation dependant).""" bmap = None # priority: multiplayer -> spectator -> last np match = ctx.player.match spectating = ctx.player.spectating if match and match.map_id: bmap = await Beatmap.from_md5(match.map_md5) elif spectating and spectating.status.map_id: bmap = await Beatmap.from_md5(spectating.status.map_md5) elif ctx.player.last_np is not None and time.time() < ctx.player.last_np["timeout"]: bmap = ctx.player.last_np["bmap"] if bmap is None: return "No map found!" return f"[{app.settings.MIRROR_DOWNLOAD_ENDPOINT}/{bmap.set_id} {bmap.full_name}]" @command(Privileges.UNRESTRICTED, aliases=["last", "r"]) async def recent(ctx: Context) -> str | None: """Show information about a player's most recent score.""" if ctx.args: target = app.state.sessions.players.get(name=" ".join(ctx.args)) if not target: return "Player not found." else: target = ctx.player score = target.recent_score if not score: return "No scores found (only saves per play session)." if score.bmap is None: return "We don't have a beatmap on file for your recent score." l = [f"[{score.mode!r}] {score.bmap.embed}", f"{score.acc:.2f}%"] if score.mods: l.insert(1, f"+{score.mods!r}") l = [" ".join(l)] if score.passed: rank = score.rank if score.status == SubmissionStatus.BEST else "NA" l.append(f"PASS {{{score.pp:.2f}pp #{rank}}}") else: # XXX: prior to v3.2.0, bancho.py didn't parse total_length from # the osu!api, and thus this can do some zerodivision moments. # this can probably be removed in the future, or better yet # replaced with a better system to fix the maps. if score.bmap.total_length != 0: completion = score.time_elapsed / (score.bmap.total_length * 1000) l.append(f"FAIL {{{completion * 100:.2f}% complete}})") else: l.append("FAIL") return " | ".join(l) TOP_SCORE_FMTSTR = "{idx}. ({pp:.2f}pp) [https://osu.{domain}/b/{map_id} {artist} - {title} [{version}]]" @command(Privileges.UNRESTRICTED, hidden=True) async def top(ctx: Context) -> str | None: """Show information about a player's top 10 scores.""" # !top (player) args_len = len(ctx.args) if args_len not in (1, 2): return "Invalid syntax: !top (player)" if ctx.args[0] not in GAMEMODE_REPR_LIST: return f'Valid gamemodes: {", ".join(GAMEMODE_REPR_LIST)}.' if ctx.args[0] in ( "rx!mania", "ap!taiko", "ap!catch", "ap!mania", ): return "Impossible gamemode combination." if args_len == 2: if not regexes.USERNAME.match(ctx.args[1]): return "Invalid username." # specific player provided player = app.state.sessions.players.get(name=ctx.args[1]) if not player: return "Player not found." else: # no player provided, use self player = ctx.player # !top rx!std mode = GAMEMODE_REPR_LIST.index(ctx.args[0]) scores = await app.state.services.database.fetch_all( "SELECT s.pp, b.artist, b.title, b.version, b.set_id map_set_id, b.id map_id " "FROM scores s " "LEFT JOIN maps b ON b.md5 = s.map_md5 " "WHERE s.userid = :user_id " "AND s.mode = :mode " "AND s.status = 2 " "AND b.status in (2, 3) " "ORDER BY s.pp DESC LIMIT 10", {"user_id": player.id, "mode": mode}, ) if not scores: return "No scores" return "\n".join( [f"Top 10 scores for {player.embed} ({ctx.args[0]})."] + [ TOP_SCORE_FMTSTR.format(idx=idx + 1, domain=app.settings.DOMAIN, **s) for idx, s in enumerate(scores) ], ) class ParsingError(str): ... def parse__with__command_args( mode: int, args: Sequence[str], ) -> Mapping[str, Any] | ParsingError: """Parse arguments for the !with command.""" if not args or len(args) > 4: return ParsingError("Invalid syntax: !with ") # !with 95% 1m 429x hddt acc = mods = combo = nmiss = None # parse acc, misses, combo and mods from arguments. # tried to balance complexity vs correctness here for arg in (str.lower(arg) for arg in args): # mandatory suffix, combo & nmiss if combo is None and arg.endswith("x") and arg[:-1].isdecimal(): combo = int(arg[:-1]) # if combo > bmap.max_combo: # return "Invalid combo." elif nmiss is None and arg.endswith("m") and arg[:-1].isdecimal(): nmiss = int(arg[:-1]) # TODO: store nobjects? # if nmiss > bmap.combo: # return "Invalid misscount." else: # optional prefix/suffix, mods & accuracy arg_stripped = arg.removeprefix("+").removesuffix("%") if mods is None and arg_stripped.isalpha() and len(arg_stripped) % 2 == 0: mods = Mods.from_modstr(arg_stripped) mods = mods.filter_invalid_combos(mode) elif acc is None and arg_stripped.replace(".", "", 1).isdecimal(): acc = float(arg_stripped) if not 0 <= acc <= 100: return ParsingError("Invalid accuracy.") else: return ParsingError(f"Unknown argument: {arg}") return { "acc": acc, "mods": mods, "combo": combo, "nmiss": nmiss, } @command(Privileges.UNRESTRICTED, aliases=["w"], hidden=True) async def _with(ctx: Context) -> str | None: """Specify custom accuracy & mod combinations with `/np`.""" if ctx.recipient is not app.state.sessions.bot: return "This command can only be used in DM with bot." if ctx.player.last_np is None or time.time() >= ctx.player.last_np["timeout"]: return "Please /np a map first!" bmap: Beatmap = ctx.player.last_np["bmap"] osu_file_available = await ensure_osu_file_is_available( bmap.id, expected_md5=bmap.md5, ) if not osu_file_available: return "Mapfile could not be found; this incident has been reported." mode_vn = ctx.player.last_np["mode_vn"] command_args = parse__with__command_args(mode_vn, ctx.args) if isinstance(command_args, ParsingError): return str(command_args) msg_fields = [] score_args = ScoreParams(mode=mode_vn) mods = command_args["mods"] if mods is not None: score_args.mods = mods msg_fields.append(f"{mods!r}") nmiss = command_args["nmiss"] if nmiss: score_args.nmiss = nmiss msg_fields.append(f"{nmiss}m") combo = command_args["combo"] if combo is not None: score_args.combo = combo msg_fields.append(f"{combo}x") acc = command_args["acc"] if acc is not None: score_args.acc = acc msg_fields.append(f"{acc:.2f}%") result = app.usecases.performance.calculate_performances( osu_file_path=str(BEATMAPS_PATH / f"{bmap.id}.osu"), scores=[score_args], # calculate one score ) return "{msg}: {pp:.2f}pp ({stars:.2f}*)".format( msg=" ".join(msg_fields), pp=result[0]["performance"]["pp"], stars=result[0]["difficulty"]["stars"], # (first score result) ) @command(Privileges.UNRESTRICTED, aliases=["req"]) async def request(ctx: Context) -> str | None: """Request a beatmap for nomination.""" if ctx.args: return "Invalid syntax: !request" if ctx.player.last_np is None or time.time() >= ctx.player.last_np["timeout"]: return "Please /np a map first!" bmap = ctx.player.last_np["bmap"] if bmap.status != RankedStatus.Pending: return "Only pending maps may be requested for status change." map_requests = await map_requests_repo.fetch_all( map_id=bmap.id, player_id=ctx.player.id, active=True, ) if map_requests: return "You already have an active nomination request for that map." await map_requests_repo.create(map_id=bmap.id, player_id=ctx.player.id, active=True) return "Request submitted." @command(Privileges.UNRESTRICTED) async def apikey(ctx: Context) -> str | None: """Generate a new api key & assign it to the player.""" if ctx.recipient is not app.state.sessions.bot: return f"Command only available in DMs with {app.state.sessions.bot.name}." # remove old token if ctx.player.api_key: app.state.sessions.api_keys.pop(ctx.player.api_key) # generate new token ctx.player.api_key = str(uuid.uuid4()) await users_repo.partial_update(ctx.player.id, api_key=ctx.player.api_key) app.state.sessions.api_keys[ctx.player.api_key] = ctx.player.id return f"API key generated. Copy your api key from (this url)[http://{ctx.player.api_key}]." """ Nominator commands # The commands below allow users to # manage the server's state of beatmaps. """ @command(Privileges.NOMINATOR, aliases=["reqs"], hidden=True) async def requests(ctx: Context) -> str | None: """Check the nomination request queue.""" if ctx.args: return "Invalid syntax: !requests" rows = await map_requests_repo.fetch_all(active=True) if not rows: return "The queue is clean! (0 map request(s))" # group rows into {map_id: [map_request, ...]} grouped: dict[int, list[map_requests_repo.MapRequest]] = {} for row in rows: if row["map_id"] not in grouped: grouped[row["map_id"]] = [] grouped[row["map_id"]].append(row) if not grouped: return "The queue is clean! (0 map request(s))" l = [f"Total requested beatmaps: {len(grouped)}"] for map_id, reviews in grouped.items(): assert len(reviews) != 0 bmap = await Beatmap.from_bid(map_id) if not bmap: log(f"Failed to find requested map ({map_id})?", Ansi.LYELLOW) continue first_review = min(reviews, key=lambda r: r["datetime"]) l.append( f"{len(reviews)}x request(s) starting {first_review['datetime']:%Y-%m-%d}: {bmap.embed}", ) return "\n".join(l) _status_str_to_int_map = {"unrank": 0, "rank": 2, "love": 5} def status_to_id(s: str) -> int: return _status_str_to_int_map[s] @command(Privileges.NOMINATOR) async def _map(ctx: Context) -> str | None: """Changes the ranked status of the most recently /np'ed map.""" if ( len(ctx.args) != 2 or ctx.args[0] not in ("rank", "unrank", "love") or ctx.args[1] not in ("set", "map") ): return "Invalid syntax: !map " if ctx.player.last_np is None or time.time() >= ctx.player.last_np["timeout"]: return "Please /np a map first!" bmap = ctx.player.last_np["bmap"] new_status = RankedStatus(status_to_id(ctx.args[0])) if ctx.args[1] == "map": if bmap.status == new_status: return f"{bmap.embed} is already {new_status!s}!" else: # ctx.args[1] == "set" if all(map.status == new_status for map in bmap.set.maps): return f"All maps from the set are already {new_status!s}!" # update sql & cache based on scope # XXX: not sure if getting md5s from sql # for updating cache would be faster? # surely this will not scale as well... async with app.state.services.database.transaction(): if ctx.args[1] == "set": # update all maps in the set for _bmap in bmap.set.maps: await maps_repo.partial_update(_bmap.id, status=new_status, frozen=True) # make sure cache and db are synced about the newest change for _bmap in app.state.cache.beatmapset[bmap.set_id].maps: _bmap.status = new_status _bmap.frozen = True # select all map ids for clearing map requests. modified_beatmap_ids = [ row["id"] for row in await maps_repo.fetch_many( set_id=bmap.set_id, ) ] else: # update only map await maps_repo.partial_update(bmap.id, status=new_status, frozen=True) # make sure cache and db are synced about the newest change if bmap.md5 in app.state.cache.beatmap: app.state.cache.beatmap[bmap.md5].status = new_status app.state.cache.beatmap[bmap.md5].frozen = True modified_beatmap_ids = [bmap.id] # deactivate rank requests for all ids await map_requests_repo.mark_batch_as_inactive(map_ids=modified_beatmap_ids) return f"{bmap.embed} updated to {new_status!s}." """ Mod commands # The commands below are somewhat dangerous, # and are generally for managing players. """ ACTION_STRINGS = { "restrict": "Restricted for", "unrestrict": "Unrestricted for", "silence": "Silenced for", "unsilence": "Unsilenced for", "note": "Note added:", } @command(Privileges.MODERATOR, hidden=True) async def notes(ctx: Context) -> str | None: """Retrieve the logs of a specified player by name.""" if len(ctx.args) != 2 or not ctx.args[1].isdecimal(): return "Invalid syntax: !notes " target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' days = int(ctx.args[1]) if days > 365: return "Please contact a developer to fetch >365 day old information." elif days <= 0: return "Invalid syntax: !notes " res = await app.state.services.database.fetch_all( "SELECT `action`, `msg`, `time`, `from` " "FROM `logs` WHERE `to` = :to " "AND UNIX_TIMESTAMP(`time`) >= UNIX_TIMESTAMP(NOW()) - :seconds " "ORDER BY `time` ASC", {"to": target.id, "seconds": days * 86400}, ) if not res: return f"No notes found on {target} in the past {days} days." notes = [] for row in res: logger = await app.state.sessions.players.from_cache_or_sql(id=row["from"]) if not logger: continue action_str = ACTION_STRINGS.get(row["action"], "Unknown action:") time_str = row["time"] note = row["msg"] notes.append(f"[{time_str}] {action_str} {note} by {logger.name}") return "\n".join(notes) @command(Privileges.MODERATOR, hidden=True) async def addnote(ctx: Context) -> str | None: """Add a note to a specified player by name.""" if len(ctx.args) < 2: return "Invalid syntax: !addnote " target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' await logs_repo.create( _from=ctx.player.id, to=target.id, action="note", msg=" ".join(ctx.args[1:]), ) return f"Added note to {target}." # some shorthands that can be used as # reasons in many moderative commands. SHORTHAND_REASONS = { "aa": "having their appeal accepted", "cc": "using a modified osu! client", "3p": "using 3rd party programs", "rx": "using 3rd party programs (relax)", "tw": "using 3rd party programs (timewarp)", "au": "using 3rd party programs (auto play)", } @command(Privileges.MODERATOR, hidden=True) async def silence(ctx: Context) -> str | None: """Silence a specified player with a specified duration & reason.""" if len(ctx.args) < 3: return "Invalid syntax: !silence " target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' if target.priv & Privileges.STAFF and not ctx.player.priv & Privileges.DEVELOPER: return "Only developers can manage staff members." duration = timeparse(ctx.args[1]) if not duration: return "Invalid timespan." reason = " ".join(ctx.args[2:]) if reason in SHORTHAND_REASONS: reason = SHORTHAND_REASONS[reason] await target.silence(ctx.player, duration, reason) return f"{target} was silenced." @command(Privileges.MODERATOR, hidden=True) async def unsilence(ctx: Context) -> str | None: """Unsilence a specified player.""" if len(ctx.args) < 2: return "Invalid syntax: !unsilence " target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' if not target.silenced: return f"{target} is not silenced." if target.priv & Privileges.STAFF and not ctx.player.priv & Privileges.DEVELOPER: return "Only developers can manage staff members." reason = " ".join(ctx.args[1:]) await target.unsilence(ctx.player, reason) return f"{target} was unsilenced." """ Admin commands # The commands below are relatively dangerous, # and are generally for managing players. """ @command(Privileges.ADMINISTRATOR, aliases=["u"], hidden=True) async def user(ctx: Context) -> str | None: """Return general information about a given user.""" if not ctx.args: # no username specified, use ctx.player player = ctx.player else: # username given, fetch the player maybe_player = await app.state.sessions.players.from_cache_or_sql( name=" ".join(ctx.args), ) if maybe_player is None: return "Player not found." player = maybe_player priv_list = [ priv.name for priv in Privileges if player.priv & priv and bin(priv).count("1") == 1 ][::-1] if player.last_np is not None and time.time() < player.last_np["timeout"]: last_np = player.last_np["bmap"].embed else: last_np = None if player.is_online and player.client_details is not None: osu_version = player.client_details.osu_version.date.isoformat() else: osu_version = "Unknown" donator_info = ( f"True (ends {timeago.format(player.donor_end)})" if player.priv & Privileges.DONATOR != 0 else "False" ) user_clan = ( await clans_repo.fetch_one(id=player.clan_id) if player.clan_id is not None else None ) display_name = ( f"[{user_clan['tag']}] {player.name}" if user_clan is not None else player.name ) return "\n".join( ( f'[{"Bot" if player.is_bot_client else "Player"}] {display_name} ({player.id})', f"Privileges: {priv_list}", f"Donator: {donator_info}", f"Channels: {[c._name for c in player.channels]}", f"Logged in: {timeago.format(player.login_time)}", f"Last server interaction: {timeago.format(player.last_recv_time)}", f"osu! build: {osu_version} | Tourney: {player.is_tourney_client}", f"Silenced: {player.silenced} | Spectating: {player.spectating}", f"Last /np: {last_np}", f"Recent score: {player.recent_score}", f"Match: {player.match}", f"Spectators: {player.spectators}", ), ) @command(Privileges.ADMINISTRATOR, hidden=True) async def restrict(ctx: Context) -> str | None: """Restrict a specified player's account, with a reason.""" if len(ctx.args) < 2: return "Invalid syntax: !restrict " # find any user matching (including offline). target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' if target.priv & Privileges.STAFF and not ctx.player.priv & Privileges.DEVELOPER: return "Only developers can manage staff members." if target.restricted: return f"{target} is already restricted!" reason = " ".join(ctx.args[1:]) if reason in SHORTHAND_REASONS: reason = SHORTHAND_REASONS[reason] await target.restrict(admin=ctx.player, reason=reason) # refresh their client state if target.is_online: target.logout() return f"{target} was restricted." @command(Privileges.ADMINISTRATOR, hidden=True) async def unrestrict(ctx: Context) -> str | None: """Unrestrict a specified player's account, with a reason.""" if len(ctx.args) < 2: return "Invalid syntax: !unrestrict " # find any user matching (including offline). target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return f'"{ctx.args[0]}" not found.' if target.priv & Privileges.STAFF and not ctx.player.priv & Privileges.DEVELOPER: return "Only developers can manage staff members." if not target.restricted: return f"{target} is not restricted!" reason = " ".join(ctx.args[1:]) if reason in SHORTHAND_REASONS: reason = SHORTHAND_REASONS[reason] await target.unrestrict(ctx.player, reason) # refresh their client state if target.is_online: target.logout() return f"{target} was unrestricted." @command(Privileges.ADMINISTRATOR, hidden=True) async def alert(ctx: Context) -> str | None: """Send a notification to all players.""" if len(ctx.args) < 1: return "Invalid syntax: !alert " notif_txt = " ".join(ctx.args) app.state.sessions.players.enqueue(app.packets.notification(notif_txt)) return "Alert sent." @command(Privileges.ADMINISTRATOR, aliases=["alertu"], hidden=True) async def alertuser(ctx: Context) -> str | None: """Send a notification to a specified player by name.""" if len(ctx.args) < 2: return "Invalid syntax: !alertu " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." notif_txt = " ".join(ctx.args[1:]) target.enqueue(app.packets.notification(notif_txt)) return "Alert sent." # NOTE: this is pretty useless since it doesn't switch anything other # than the c[e4].ppy.sh domains; it exists on bancho as a tournament # server switch mechanism, perhaps we could leverage this in the future. @command(Privileges.ADMINISTRATOR, hidden=True) async def switchserv(ctx: Context) -> str | None: """Switch your client's internal endpoints to a specified IP address.""" if len(ctx.args) != 1: return "Invalid syntax: !switch " new_bancho_ip = ctx.args[0] ctx.player.enqueue(app.packets.switch_tournament_server(new_bancho_ip)) return "Have a nice journey.." @command(Privileges.ADMINISTRATOR) async def shutdown(ctx: Context) -> str | None | NoReturn: """Gracefully shutdown the server.""" if ctx.args: # shutdown after a delay delay = timeparse(ctx.args[0]) if not delay: return "Invalid timespan." if delay < 15: return "Minimum delay is 15 seconds." if len(ctx.args) > 1: # alert all online players of the reboot. alert_msg = ( f"The server will {ctx.trigger} in {ctx.args[0]}.\n\n" f'Reason: {" ".join(ctx.args[1:])}' ) app.state.sessions.players.enqueue(app.packets.notification(alert_msg)) app.state.loop.call_later(delay, os.kill, os.getpid(), signal.SIGTERM) return f"Enqueued {ctx.trigger}." else: # shutdown immediately os.kill(os.getpid(), signal.SIGTERM) return "Process killed" """ Developer commands # The commands below are either dangerous or # simply not useful for any other roles. """ @command(Privileges.DEVELOPER) async def stealth(ctx: Context) -> str | None: """Toggle the developer's stealth, allowing them to be hidden.""" # NOTE: this command is a large work in progress and currently # half works; eventually it will be moved to the Admin level. ctx.player.stealth = not ctx.player.stealth return f'Stealth {"enabled" if ctx.player.stealth else "disabled"}.' @command(Privileges.DEVELOPER) async def recalc(ctx: Context) -> str | None: """Recalculate pp for a given map, or all maps.""" return ( "Please use tools/recalc.py instead.\n" "If you need any support, join our Discord @ https://discord.gg/ShEQgUx." ) @command(Privileges.DEVELOPER, hidden=True) async def debug(ctx: Context) -> str | None: """Toggle the console's debug setting.""" app.settings.DEBUG = not app.settings.DEBUG return f"Toggled {'on' if app.settings.DEBUG else 'off'}." # NOTE: these commands will likely be removed # with the addition of a good frontend. str_priv_dict = { "normal": Privileges.UNRESTRICTED, "verified": Privileges.VERIFIED, "whitelisted": Privileges.WHITELISTED, "supporter": Privileges.SUPPORTER, "premium": Privileges.PREMIUM, "alumni": Privileges.ALUMNI, "tournament": Privileges.TOURNEY_MANAGER, "nominator": Privileges.NOMINATOR, "mod": Privileges.MODERATOR, "admin": Privileges.ADMINISTRATOR, "developer": Privileges.DEVELOPER, } @command(Privileges.DEVELOPER, hidden=True) async def addpriv(ctx: Context) -> str | None: """Set privileges for a specified player (by name).""" if len(ctx.args) < 2: return "Invalid syntax: !addpriv " bits = Privileges(0) for m in [m.lower() for m in ctx.args[1:]]: if m not in str_priv_dict: return f"Not found: {m}." bits |= str_priv_dict[m] target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return "Could not find user." if bits & Privileges.DONATOR != 0: return "Please use the !givedonator command to assign donator privileges to players." await target.add_privs(bits) return f"Updated {target}'s privileges." @command(Privileges.DEVELOPER, hidden=True) async def rmpriv(ctx: Context) -> str | None: """Set privileges for a specified player (by name).""" if len(ctx.args) < 2: return "Invalid syntax: !rmpriv " bits = Privileges(0) for m in [m.lower() for m in ctx.args[1:]]: if m not in str_priv_dict: return f"Not found: {m}." bits |= str_priv_dict[m] target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return "Could not find user." await target.remove_privs(bits) if bits & Privileges.DONATOR != 0: target.donor_end = 0 await app.state.services.database.execute( "UPDATE users SET donor_end = 0 WHERE id = :user_id", {"user_id": target.id}, ) return f"Updated {target}'s privileges." @command(Privileges.DEVELOPER, hidden=True) async def givedonator(ctx: Context) -> str | None: """Give donator status to a specified player for a specified duration.""" if len(ctx.args) < 2: return "Invalid syntax: !givedonator " target = await app.state.sessions.players.from_cache_or_sql(name=ctx.args[0]) if not target: return "Could not find user." timespan = timeparse(ctx.args[1]) if not timespan: return "Invalid timespan." if target.donor_end < time.time(): timespan += time.time() else: timespan += target.donor_end target.donor_end = int(timespan) await app.state.services.database.execute( "UPDATE users SET donor_end = :end WHERE id = :user_id", {"end": timespan, "user_id": target.id}, ) await target.add_privs(Privileges.SUPPORTER) return f"Added {ctx.args[1]} of donator status to {target}." @command(Privileges.DEVELOPER) async def wipemap(ctx: Context) -> str | None: # (intentionally no docstring) if ctx.args: return "Invalid syntax: !wipemap" if ctx.player.last_np is None or time.time() >= ctx.player.last_np["timeout"]: return "Please /np a map first!" map_md5 = ctx.player.last_np["bmap"].md5 # delete scores from all tables await app.state.services.database.execute( "DELETE FROM scores WHERE map_md5 = :map_md5", {"map_md5": map_md5}, ) return "Scores wiped." @command(Privileges.DEVELOPER, aliases=["re"]) async def reload(ctx: Context) -> str | None: """Reload a python module.""" if len(ctx.args) != 1: return "Invalid syntax: !reload " parent, *children = ctx.args[0].split(".") try: mod = __import__(parent) except ModuleNotFoundError: return "Module not found." child = None try: for child in children: mod = getattr(mod, child) except AttributeError: return f"Failed at {child}." try: mod = importlib.reload(mod) except TypeError as exc: return f"{exc.args[0]}." return f"Reloaded {mod.__name__}" @command(Privileges.UNRESTRICTED) async def server(ctx: Context) -> str | None: """Retrieve performance data about the server.""" build_str = f"bancho.py v{app.settings.VERSION} ({app.settings.DOMAIN})" # get info about this process proc = psutil.Process(os.getpid()) uptime = int(time.time() - proc.create_time()) # get info about our cpu cpu_info = cpuinfo.get_cpu_info() # list of all cpus installed with thread count thread_count = cpu_info["count"] cpu_name = cpu_info["brand_raw"] cpu_info_str = f"{thread_count}x {cpu_name}" # get system-wide ram usage sys_ram = psutil.virtual_memory() # output ram usage as `{bancho_used}MB / {sys_used}MB / {sys_total}MB` bancho_ram = proc.memory_info()[0] ram_values = (bancho_ram, sys_ram.used, sys_ram.total) ram_info = " / ".join([f"{v // 1024 ** 2}MB" for v in ram_values]) # current state of settings mirror_search_url = urlparse(app.settings.MIRROR_SEARCH_ENDPOINT).netloc mirror_download_url = urlparse(app.settings.MIRROR_DOWNLOAD_ENDPOINT).netloc using_osuapi = bool(app.settings.OSU_API_KEY) advanced_mode = app.settings.DEVELOPER_MODE auto_logging = app.settings.AUTOMATICALLY_REPORT_PROBLEMS # package versioning info # divide up pkg versions, 3 displayed per line, e.g. # aiohttp v3.6.3 | aiomysql v0.0.21 | bcrypt v3.2.0 # cmyui v1.7.3 | datadog v0.40.1 | geoip2 v4.1.0 # maniera v1.0.0 | mysql-connector-python v8.0.23 | orjson v3.5.1 # psutil v5.8.0 | py3rijndael v0.3.3 | uvloop v0.15.2 requirements = [] for dist in importlib.metadata.distributions(): requirements.append(f"{dist.name} v{dist.version}") requirements.sort(key=lambda x: x.casefold()) requirements_info = "\n".join( " | ".join(section) for section in (requirements[i : i + 3] for i in range(0, len(requirements), 3)) ) return "\n".join( ( f"{build_str} | uptime: {timedelta(seconds=uptime)}", f"cpu: {cpu_info_str}", f"ram: {ram_info}", f"search mirror: {mirror_search_url} | download mirror: {mirror_download_url}", f"osu!api connection: {using_osuapi}", f"advanced mode: {advanced_mode} | auto logging: {auto_logging}", "", "requirements", requirements_info, ), ) if app.settings.DEVELOPER_MODE: """Advanced (& potentially dangerous) commands""" # NOTE: some of these commands are potentially dangerous, and only # really intended for advanced users looking for access to lower level # utilities. Some may give direct access to utilties that could perform # harmful tasks to the underlying machine, so use at your own risk. from sys import modules as installed_mods __py_namespace: dict[str, Any] = globals() | { mod: importlib.import_module(mod) for mod in ( "asyncio", "dis", "os", "sys", "struct", "discord", "datetime", "time", "inspect", "math", "importlib", ) if mod in installed_mods } @command(Privileges.DEVELOPER) async def py(ctx: Context) -> str | None: """Allow for (async) access to the python interpreter.""" # This can be very good for getting used to bancho.py's API; just look # around the codebase and find things to play with in your server. # Ex: !py return (await app.state.sessions.players.get(name='cmyui')).status.action if not ctx.args: return "owo" # turn our input args into a coroutine definition string. definition = "\n ".join(["async def __py(ctx):", " ".join(ctx.args)]) try: # def __py(ctx) exec(definition, __py_namespace) # add to namespace ret = await __py_namespace["__py"](ctx) # await it's return except Exception as exc: # return exception in osu! chat ret = f"{exc.__class__}: {exc}" if "__py" in __py_namespace: del __py_namespace["__py"] if not isinstance(ret, str): ret = pprint.pformat(ret, compact=True) return str(ret) """ Multiplayer commands # The commands below for multiplayer match management. # Most commands are open to player usage. """ def ensure_match( f: Callable[[Context, Match], Awaitable[str | None]], ) -> Callable[[Context], Awaitable[str | None]]: @wraps(f) async def wrapper(ctx: Context) -> str | None: match = ctx.player.match # multi set is a bit of a special case, # as we do some additional checks. if match is None: # player not in a match return None if ctx.recipient is not match.chat: # message not in match channel return None if not ( ctx.player in match.refs or ctx.player.priv & Privileges.TOURNEY_MANAGER or f is mp_help.__wrapped__ # type: ignore[attr-defined] ): return None return await f(ctx, match) return wrapper @mp_commands.add(Privileges.UNRESTRICTED, aliases=["h"]) @ensure_match async def mp_help(ctx: Context, match: Match) -> str | None: """Show all documented multiplayer commands the player can access.""" prefix = app.settings.COMMAND_PREFIX cmds = [] for cmd in mp_commands.commands: if not cmd.doc or ctx.player.priv & cmd.priv != cmd.priv: # no doc, or insufficient permissions. continue cmds.append(f"{prefix}mp {cmd.triggers[0]}: {cmd.doc}") return "\n".join(cmds) @mp_commands.add(Privileges.UNRESTRICTED, aliases=["st"]) @ensure_match async def mp_start(ctx: Context, match: Match) -> str | None: """Start the current multiplayer match, with any players ready.""" if len(ctx.args) > 1: return "Invalid syntax: !mp start " # this command can be used in a few different ways; # !mp start: start the match now (make sure all players are ready) # !mp start force: start the match now (don't check for ready) # !mp start N: start the match in N seconds (don't check for ready) # !mp start cancel: cancel the current match start timer if not ctx.args: # !mp start if match.starting is not None: time_remaining = int(match.starting["time"] - time.time()) return f"Match starting in {time_remaining} seconds." if any([s.status == SlotStatus.not_ready for s in match.slots]): return "Not all players are ready (`!mp start force` to override)." else: if ctx.args[0].isdecimal(): # !mp start N if match.starting is not None: time_remaining = int(match.starting["time"] - time.time()) return f"Match starting in {time_remaining} seconds." # !mp start duration = int(ctx.args[0]) if not 0 < duration <= 300: return "Timer range is 1-300 seconds." def _start() -> None: """Remove any pending timers & start the match.""" # remove start & alert timers match.starting = None # make sure player didn't leave the # match since queueing this start lol... if ctx.player not in {slot.player for slot in match.slots}: match.chat.send_bot("Player left match? (cancelled)") return match.start() match.chat.send_bot("Starting match.") def _alert_start(t: int) -> None: """Alert the match of the impending start.""" match.chat.send_bot(f"Match starting in {t} seconds.") # add timers to our match object, # so we can cancel them if needed. match.starting = { "start": app.state.loop.call_later(duration, _start), "alerts": [ app.state.loop.call_later(duration - t, lambda t=t: _alert_start(t)) for t in (60, 30, 10, 5, 4, 3, 2, 1) if t < duration ], "time": time.time() + duration, } return f"Match will start in {duration} seconds." elif ctx.args[0] in ("cancel", "c"): # !mp start cancel if match.starting is None: return "Match timer not active!" match.starting["start"].cancel() for alert in match.starting["alerts"]: alert.cancel() match.starting = None return "Match timer cancelled." elif ctx.args[0] not in ("force", "f"): return "Invalid syntax: !mp start " # !mp start force simply passes through match.start() return "Good luck!" @mp_commands.add(Privileges.UNRESTRICTED, aliases=["a"]) @ensure_match async def mp_abort(ctx: Context, match: Match) -> str | None: """Abort the current in-progress multiplayer match.""" if not match.in_progress: return "Abort what?" match.unready_players(expected=SlotStatus.playing) match.reset_players_loaded_status() match.in_progress = False match.enqueue(app.packets.match_abort()) match.enqueue_state() return "Match aborted." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_map(ctx: Context, match: Match) -> str | None: """Set the current match's current map by id.""" if len(ctx.args) != 1 or not ctx.args[0].isdecimal(): return "Invalid syntax: !mp map " map_id = int(ctx.args[0]) if map_id == match.map_id: return "Map already selected." bmap = await Beatmap.from_bid(map_id) if not bmap: return "Beatmap not found." match.map_id = bmap.id match.map_md5 = bmap.md5 match.map_name = bmap.full_name match.mode = bmap.mode match.enqueue_state() return f"Selected: {bmap.embed}." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_mods(ctx: Context, match: Match) -> str | None: """Set the current match's mods, from string form.""" if len(ctx.args) != 1 or len(ctx.args[0]) % 2 != 0: return "Invalid syntax: !mp mods " mods = Mods.from_modstr(ctx.args[0]) mods = mods.filter_invalid_combos(match.mode.as_vanilla) if match.freemods: if ctx.player is match.host: # allow host to set speed-changing mods. match.mods = mods & SPEED_CHANGING_MODS # set slot mods slot = match.get_slot(ctx.player) assert slot is not None slot.mods = mods & ~SPEED_CHANGING_MODS else: # not freemods, set match mods. match.mods = mods match.enqueue_state() return "Match mods updated." @mp_commands.add(Privileges.UNRESTRICTED, aliases=["fm", "fmods"]) @ensure_match async def mp_freemods(ctx: Context, match: Match) -> str | None: """Toggle freemods status for the match.""" if len(ctx.args) != 1 or ctx.args[0] not in ("on", "off"): return "Invalid syntax: !mp freemods " if ctx.args[0] == "on": # central mods -> all players mods. match.freemods = True for s in match.slots: if s.player is not None: # the slot takes any non-speed # changing mods from the match. s.mods = match.mods & ~SPEED_CHANGING_MODS match.mods &= SPEED_CHANGING_MODS else: # host mods -> central mods. match.freemods = False host_slot = match.get_host_slot() assert host_slot is not None # the match keeps any speed-changing mods, # and also takes any mods the host has enabled. match.mods &= SPEED_CHANGING_MODS match.mods |= host_slot.mods for s in match.slots: if s.player is not None: s.mods = Mods.NOMOD match.enqueue_state() return "Match freemod status updated." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_host(ctx: Context, match: Match) -> str | None: """Set the current match's current host by id.""" if len(ctx.args) != 1: return "Invalid syntax: !mp host " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." if target is match.host: return "They're already host, silly!" if target not in {slot.player for slot in match.slots}: return "Found no such player in the match." match.host_id = target.id match.host.enqueue(app.packets.match_transfer_host()) match.enqueue_state(lobby=True) return "Match host updated." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_randpw(ctx: Context, match: Match) -> str | None: """Randomize the current match's password.""" match.passwd = secrets.token_hex(8) return "Match password randomized." @mp_commands.add(Privileges.UNRESTRICTED, aliases=["inv"]) @ensure_match async def mp_invite(ctx: Context, match: Match) -> str | None: """Invite a player to the current match by name.""" if len(ctx.args) != 1: return "Invalid syntax: !mp invite " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." if target is app.state.sessions.bot: return "I'm too busy!" if target is ctx.player: return "You can't invite yourself!" target.enqueue(app.packets.match_invite(ctx.player, target.name)) return f"Invited {target} to the match." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_addref(ctx: Context, match: Match) -> str | None: """Add a referee to the current match by name.""" if len(ctx.args) != 1: return "Invalid syntax: !mp addref " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." if target not in {slot.player for slot in match.slots}: return "User must be in the current match!" if target in match.refs: return f"{target} is already a match referee!" match._refs.add(target) return f"{target.name} added to match referees." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_rmref(ctx: Context, match: Match) -> str | None: """Remove a referee from the current match by name.""" if len(ctx.args) != 1: return "Invalid syntax: !mp addref " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." if target not in match.refs: return f"{target} is not a match referee!" if target is match.host: return "The host is always a referee!" match._refs.remove(target) return f"{target.name} removed from match referees." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_listref(ctx: Context, match: Match) -> str | None: """List all referees from the current match.""" return ", ".join(map(str, match.refs)) + "." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_lock(ctx: Context, match: Match) -> str | None: """Lock all unused slots in the current match.""" for slot in match.slots: if slot.status == SlotStatus.open: slot.status = SlotStatus.locked match.enqueue_state() return "All unused slots locked." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_unlock(ctx: Context, match: Match) -> str | None: """Unlock locked slots in the current match.""" for slot in match.slots: if slot.status == SlotStatus.locked: slot.status = SlotStatus.open match.enqueue_state() return "All locked slots unlocked." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_teams(ctx: Context, match: Match) -> str | None: """Change the team type for the current match.""" if len(ctx.args) != 1: return "Invalid syntax: !mp teams " team_type = ctx.args[0] if team_type in ("ffa", "freeforall", "head-to-head"): match.team_type = MatchTeamTypes.head_to_head elif team_type in ("tag", "coop", "co-op", "tag-coop"): match.team_type = MatchTeamTypes.tag_coop elif team_type in ("teams", "team-vs", "teams-vs"): match.team_type = MatchTeamTypes.team_vs elif team_type in ("tag-teams", "tag-team-vs", "tag-teams-vs"): match.team_type = MatchTeamTypes.tag_team_vs else: return "Unknown team type. (ffa, tag, teams, tag-teams)" # find the new appropriate default team. # defaults are (ffa: neutral, teams: red). if match.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 s in match.slots: if s.player is not None: s.team = new_t if match.is_scrimming: # reset score if scrimming. match.reset_scrim() match.enqueue_state() return "Match team type updated." @mp_commands.add(Privileges.UNRESTRICTED, aliases=["cond"]) @ensure_match async def mp_condition(ctx: Context, match: Match) -> str | None: """Change the win condition for the match.""" if len(ctx.args) != 1: return "Invalid syntax: !mp condition " cond = ctx.args[0] if cond == "pp": # special case - pp can't actually be used as an ingame # win condition, but bancho.py allows it to be passed into # this command during a scrims to use pp as a win cond. if not match.is_scrimming: return "PP is only useful as a win condition during scrims." if match.use_pp_scoring: return "PP scoring already enabled." match.use_pp_scoring = True else: if match.use_pp_scoring: match.use_pp_scoring = False if cond == "score": match.win_condition = MatchWinConditions.score elif cond in ("accuracy", "acc"): match.win_condition = MatchWinConditions.accuracy elif cond == "combo": match.win_condition = MatchWinConditions.combo elif cond in ("scorev2", "v2"): match.win_condition = MatchWinConditions.scorev2 else: return "Invalid win condition. (score, acc, combo, scorev2, *pp)" match.enqueue_state(lobby=False) return "Match win condition updated." @mp_commands.add(Privileges.UNRESTRICTED, aliases=["autoref"]) @ensure_match async def mp_scrim(ctx: Context, match: Match) -> str | None: """Start a scrim in the current match.""" if len(ctx.args) != 1: return "Invalid syntax: !mp scrim " r_match = regexes.BEST_OF.fullmatch(ctx.args[0]) if not r_match: return "Invalid syntax: !mp scrim " best_of = int(r_match[1]) if not 0 <= best_of < 16: return "Best of must be in range 0-15." winning_pts = (best_of // 2) + 1 if winning_pts != 0: # setting to real num if match.is_scrimming: return "Already scrimming!" if best_of % 2 == 0: return "Best of must be an odd number!" match.is_scrimming = True msg = ( f"A scrimmage has been started by {ctx.player.name}; " f"first to {winning_pts} points wins. Best of luck!" ) else: # setting to 0 if not match.is_scrimming: return "Not currently scrimming!" match.is_scrimming = False match.reset_scrim() msg = "Scrimming cancelled." match.winning_pts = winning_pts return msg @mp_commands.add(Privileges.UNRESTRICTED, aliases=["end"]) @ensure_match async def mp_endscrim(ctx: Context, match: Match) -> str | None: """End the current matches ongoing scrim.""" if not match.is_scrimming: return "Not currently scrimming!" match.is_scrimming = False match.reset_scrim() return "Scrimmage ended." # TODO: final score (get_score method?) @mp_commands.add(Privileges.UNRESTRICTED, aliases=["rm"]) @ensure_match async def mp_rematch(ctx: Context, match: Match) -> str | None: """Restart a scrim, or roll back previous match point.""" if ctx.args: return "Invalid syntax: !mp rematch" if ctx.player is not match.host: return "Only available to the host." if not match.is_scrimming: if match.winning_pts == 0: msg = "No scrim to rematch; to start one, use !mp scrim." else: # re-start scrimming with old points match.is_scrimming = True msg = ( f"A rematch has been started by {ctx.player.name}; " f"first to {match.winning_pts} points wins. Best of luck!" ) else: # reset the last match point awarded if not match.winners: return "No match points have yet been awarded!" recent_winner = match.winners[-1] if recent_winner is None: return "The last point was a tie!" match.match_points[recent_winner] -= 1 # TODO: team name match.winners.pop() msg = f"A point has been deducted from {recent_winner}." return msg @mp_commands.add(Privileges.ADMINISTRATOR, aliases=["f"], hidden=True) @ensure_match async def mp_force(ctx: Context, match: Match) -> str | None: """Force a player into the current match by name.""" # NOTE: this overrides any limits such as silences or passwd. if len(ctx.args) != 1: return "Invalid syntax: !mp force " target = app.state.sessions.players.get(name=ctx.args[0]) if not target: return "Could not find a user by that name." target.join_match(match, match.passwd) return "Welcome." # mappool-related mp commands @mp_commands.add(Privileges.UNRESTRICTED, aliases=["lp"]) @ensure_match async def mp_loadpool(ctx: Context, match: Match) -> str | None: """Load a mappool into the current match.""" if len(ctx.args) != 1: return "Invalid syntax: !mp loadpool " if ctx.player is not match.host: return "Only available to the host." name = ctx.args[0] tourney_pool = await tourney_pools_repo.fetch_by_name(name) if tourney_pool is None: return "Could not find a pool by that name!" if ( match.tourney_pool is not None and match.tourney_pool["id"] == tourney_pool["id"] ): return f"{tourney_pool['name']} already selected!" match.tourney_pool = tourney_pool return f"{tourney_pool['name']} selected." @mp_commands.add(Privileges.UNRESTRICTED, aliases=["ulp"]) @ensure_match async def mp_unloadpool(ctx: Context, match: Match) -> str | None: """Unload the current matches mappool.""" if ctx.args: return "Invalid syntax: !mp unloadpool" if ctx.player is not match.host: return "Only available to the host." if not match.tourney_pool: return "No mappool currently selected!" match.tourney_pool = None return "Mappool unloaded." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_ban(ctx: Context, match: Match) -> str | None: """Ban a pick in the currently loaded mappool.""" if len(ctx.args) != 1: return "Invalid syntax: !mp ban " if not match.tourney_pool: return "No pool currently selected!" mods_slot = ctx.args[0] # separate mods & slot r_match = regexes.MAPPOOL_PICK.fullmatch(mods_slot) if not r_match: return "Invalid pick syntax; correct example: HD2" # not calling mods.filter_invalid_combos here intentionally. mods = Mods.from_modstr(r_match[1]) slot = int(r_match[2]) map_pick = await tourney_pool_maps_repo.fetch_by_pool_and_pick( pool_id=match.tourney_pool["id"], mods=mods, slot=slot, ) if map_pick is None: return f"Found no {mods_slot} pick in the pool." if (mods, slot) in match.bans: return "That pick is already banned!" match.bans.add((mods, slot)) return f"{mods_slot} banned." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_unban(ctx: Context, match: Match) -> str | None: """Unban a pick in the currently loaded mappool.""" if len(ctx.args) != 1: return "Invalid syntax: !mp unban " if not match.tourney_pool: return "No pool currently selected!" mods_slot = ctx.args[0] # separate mods & slot r_match = regexes.MAPPOOL_PICK.fullmatch(mods_slot) if not r_match: return "Invalid pick syntax; correct example: HD2" # not calling mods.filter_invalid_combos here intentionally. mods = Mods.from_modstr(r_match[1]) slot = int(r_match[2]) map_pick = await tourney_pool_maps_repo.fetch_by_pool_and_pick( pool_id=match.tourney_pool["id"], mods=mods, slot=slot, ) if map_pick is None: return f"Found no {mods_slot} pick in the pool." if (mods, slot) not in match.bans: return "That pick is not currently banned!" match.bans.remove((mods, slot)) return f"{mods_slot} unbanned." @mp_commands.add(Privileges.UNRESTRICTED) @ensure_match async def mp_pick(ctx: Context, match: Match) -> str | None: """Pick a map from the currently loaded mappool.""" if len(ctx.args) != 1: return "Invalid syntax: !mp pick " if not match.tourney_pool: return "No pool currently loaded!" mods_slot = ctx.args[0] # separate mods & slot r_match = regexes.MAPPOOL_PICK.fullmatch(mods_slot) if not r_match: return "Invalid pick syntax; correct example: HD2" # not calling mods.filter_invalid_combos here intentionally. mods = Mods.from_modstr(r_match[1]) slot = int(r_match[2]) map_pick = await tourney_pool_maps_repo.fetch_by_pool_and_pick( pool_id=match.tourney_pool["id"], mods=mods, slot=slot, ) if map_pick is None: return f"Found no {mods_slot} pick in the pool." if (mods, slot) in match.bans: return f"{mods_slot} has been banned from being picked." bmap = await Beatmap.from_bid(map_pick["map_id"]) if not bmap: return f"Found no beatmap for {mods_slot} pick." match.map_md5 = bmap.md5 match.map_id = bmap.id match.map_name = bmap.full_name # TODO: some kind of abstraction allowing # for something like !mp pick fm. if match.freemods: # if freemods are enabled, disable them. match.freemods = False for s in match.slots: if s.player is not None: s.mods = Mods.NOMOD # update match mods to the picked map. match.mods = mods match.enqueue_state() return f"Picked {bmap.embed}. ({mods_slot})" """ Mappool management commands # The commands below are for event managers # and tournament hosts/referees to help automate # tedious processes of running tournaments. """ @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["h"], hidden=True) async def pool_help(ctx: Context) -> str | None: """Show all documented mappool commands the player can access.""" prefix = app.settings.COMMAND_PREFIX cmds = [] for cmd in pool_commands.commands: if not cmd.doc or ctx.player.priv & cmd.priv != cmd.priv: # no doc, or insufficient permissions. continue cmds.append(f"{prefix}pool {cmd.triggers[0]}: {cmd.doc}") return "\n".join(cmds) @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["c"], hidden=True) async def pool_create(ctx: Context) -> str | None: """Add a new mappool to the database.""" if len(ctx.args) != 1: return "Invalid syntax: !pool create " name = ctx.args[0] existing_pool = await tourney_pools_repo.fetch_by_name(name) if existing_pool is not None: return "Pool already exists by that name!" tourney_pool = await tourney_pools_repo.create( name=name, created_by=ctx.player.id, ) return f"{name} created." @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["del", "d"], hidden=True) async def pool_delete(ctx: Context) -> str | None: """Remove a mappool from the database.""" if len(ctx.args) != 1: return "Invalid syntax: !pool delete " name = ctx.args[0] existing_pool = await tourney_pools_repo.fetch_by_name(name) if existing_pool is None: return "Could not find a pool by that name!" await tourney_pools_repo.delete_by_id(existing_pool["id"]) await tourney_pool_maps_repo.delete_all_in_pool(pool_id=existing_pool["id"]) return f"{name} deleted." @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["a"], hidden=True) async def pool_add(ctx: Context) -> str | None: """Add a new map to a mappool in the database.""" if len(ctx.args) != 2: return "Invalid syntax: !pool add " if ctx.player.last_np is None or time.time() >= ctx.player.last_np["timeout"]: return "Please /np a map first!" name, mods_slot = ctx.args mods_slot = mods_slot.upper() # ocd bmap = ctx.player.last_np["bmap"] # separate mods & slot r_match = regexes.MAPPOOL_PICK.fullmatch(mods_slot) if not r_match: return "Invalid pick syntax; correct example: HD2" if len(r_match[1]) % 2 != 0: return "Invalid mods." # not calling mods.filter_invalid_combos here intentionally. mods = Mods.from_modstr(r_match[1]) slot = int(r_match[2]) tourney_pool = await tourney_pools_repo.fetch_by_name(name) if tourney_pool is None: return "Could not find a pool by that name!" tourney_pool_maps = await tourney_pool_maps_repo.fetch_many( pool_id=tourney_pool["id"], ) for pool_map in tourney_pool_maps: if mods == pool_map["mods"] and slot == pool_map["slot"]: pool_beatmap = await Beatmap.from_bid(pool_map["map_id"]) assert pool_beatmap is not None return f"{mods_slot} is already {pool_beatmap.embed}!" if pool_map["map_id"] == bmap.id: return f"{bmap.embed} is already in the pool!" await tourney_pool_maps_repo.create( map_id=bmap.id, pool_id=tourney_pool["id"], mods=mods, slot=slot, ) return f"{bmap.embed} added to {name} as {mods_slot}." @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["rm", "r"], hidden=True) async def pool_remove(ctx: Context) -> str | None: """Remove a map from a mappool in the database.""" if len(ctx.args) != 2: return "Invalid syntax: !pool remove " name, mods_slot = ctx.args mods_slot = mods_slot.upper() # ocd # separate mods & slot r_match = regexes.MAPPOOL_PICK.fullmatch(mods_slot) if not r_match: return "Invalid pick syntax; correct example: HD2" # not calling mods.filter_invalid_combos here intentionally. mods = Mods.from_modstr(r_match[1]) slot = int(r_match[2]) tourney_pool = await tourney_pools_repo.fetch_by_name(name) if tourney_pool is None: return "Could not find a pool by that name!" map_pick = await tourney_pool_maps_repo.fetch_by_pool_and_pick( pool_id=tourney_pool["id"], mods=mods, slot=slot, ) if map_pick is None: return f"Found no {mods_slot} pick in the pool." await tourney_pool_maps_repo.delete_map_from_pool( map_pick["pool_id"], map_pick["map_id"], ) return f"{mods_slot} removed from {name}." @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["l"], hidden=True) async def pool_list(ctx: Context) -> str | None: """List all existing mappools information.""" tourney_pools = await tourney_pools_repo.fetch_many(page=None, page_size=None) if not tourney_pools: return "There are currently no pools!" l = [f"Mappools ({len(tourney_pools)})"] for pool in tourney_pools: created_by = await users_repo.fetch_one(id=pool["created_by"]) if created_by is None: log(f"Could not find pool creator (Id {pool['created_by']}).", Ansi.LRED) continue l.append( f"[{pool['created_at']:%Y-%m-%d}] " f"{pool['name']}, by {created_by['name']}.", ) return "\n".join(l) @pool_commands.add(Privileges.TOURNEY_MANAGER, aliases=["i"], hidden=True) async def pool_info(ctx: Context) -> str | None: """Get all information for a specific mappool.""" if len(ctx.args) != 1: return "Invalid syntax: !pool info " name = ctx.args[0] tourney_pool = await tourney_pools_repo.fetch_by_name(name) if tourney_pool is None: return "Could not find a pool by that name!" _time = tourney_pool["created_at"].strftime("%H:%M:%S%p") _date = tourney_pool["created_at"].strftime("%Y-%m-%d") datetime_fmt = f"Created at {_time} on {_date}" l = [ f"{tourney_pool['id']}. {tourney_pool['name']}, by {tourney_pool['created_by']} | {datetime_fmt}.", ] for tourney_map in sorted( await tourney_pool_maps_repo.fetch_many(pool_id=tourney_pool["id"]), key=lambda x: (repr(Mods(x["mods"])), x["slot"]), ): bmap = await Beatmap.from_bid(tourney_map["map_id"]) if bmap is None: log(f"Could not find beatmap {tourney_map['map_id']}.", Ansi.LRED) continue l.append(f"{Mods(tourney_map['mods'])!r}{tourney_map['slot']}: {bmap.embed}") return "\n".join(l) """ Clan managment commands # The commands below are for managing bancho.py # clans, for users, clan staff, and server staff. """ @clan_commands.add(Privileges.UNRESTRICTED, aliases=["h"]) async def clan_help(ctx: Context) -> str | None: """Show all documented clan commands the player can access.""" prefix = app.settings.COMMAND_PREFIX cmds = [] for cmd in clan_commands.commands: if not cmd.doc or ctx.player.priv & cmd.priv != cmd.priv: # no doc, or insufficient permissions. continue cmds.append(f"{prefix}clan {cmd.triggers[0]}: {cmd.doc}") return "\n".join(cmds) @clan_commands.add(Privileges.UNRESTRICTED, aliases=["c"]) async def clan_create(ctx: Context) -> str | None: """Create a clan with a given tag & name.""" if len(ctx.args) < 2: return "Invalid syntax: !clan create " tag = ctx.args[0].upper() if not 1 <= len(tag) <= 6: return "Clan tag may be 1-6 characters long." name = " ".join(ctx.args[1:]) if not 2 <= len(name) <= 16: return "Clan name may be 2-16 characters long." if ctx.player.clan_id: clan = await clans_repo.fetch_one(id=ctx.player.clan_id) if clan: clan_display_name = f"[{clan['tag']}] {clan['name']}" return f"You're already a member of {clan_display_name}!" if await clans_repo.fetch_one(name=name): return "That name has already been claimed by another clan." if await clans_repo.fetch_one(tag=tag): return "That tag has already been claimed by another clan." # add clan to sql new_clan = await clans_repo.create( name=name, tag=tag, owner=ctx.player.id, ) # set owner's clan & clan priv (cache & sql) ctx.player.clan_id = new_clan["id"] ctx.player.clan_priv = ClanPrivileges.Owner await users_repo.partial_update( ctx.player.id, clan_id=new_clan["id"], clan_priv=ClanPrivileges.Owner, ) # announce clan creation announce_chan = app.state.sessions.channels.get_by_name("#announce") clan_display_name = f"[{new_clan['tag']}] {new_clan['name']}" if announce_chan: msg = f"\x01ACTION founded {clan_display_name}." announce_chan.send(msg, sender=ctx.player, to_self=True) return f"{clan_display_name} founded." @clan_commands.add(Privileges.UNRESTRICTED, aliases=["delete", "d"]) async def clan_disband(ctx: Context) -> str | None: """Disband a clan (admins may disband others clans).""" if ctx.args: # disband a specified clan by tag if ctx.player not in app.state.sessions.players.staff: return "Only staff members may disband the clans of others." clan = await clans_repo.fetch_one(tag=" ".join(ctx.args).upper()) if not clan: return "Could not find a clan by that tag." else: if ctx.player.clan_id is None: return "You're not a member of a clan!" # disband the player's clan clan = await clans_repo.fetch_one(id=ctx.player.clan_id) if not clan: return "You're not a member of a clan!" await clans_repo.delete_one(clan["id"]) # remove all members from the clan clan_member_ids = [ clan_member["id"] for clan_member in await users_repo.fetch_many(clan_id=clan["id"]) ] for member_id in clan_member_ids: await users_repo.partial_update(member_id, clan_id=0, clan_priv=0) member = app.state.sessions.players.get(id=member_id) if member: member.clan_id = None member.clan_priv = None # announce clan disbanding announce_chan = app.state.sessions.channels.get_by_name("#announce") clan_display_name = f"[{clan['tag']}] {clan['name']}" if announce_chan: msg = f"\x01ACTION disbanded {clan_display_name}." announce_chan.send(msg, sender=ctx.player, to_self=True) return f"{clan_display_name} disbanded." @clan_commands.add(Privileges.UNRESTRICTED, aliases=["i"]) async def clan_info(ctx: Context) -> str | None: """Lookup information of a clan by tag.""" if not ctx.args: return "Invalid syntax: !clan info " clan = await clans_repo.fetch_one(tag=" ".join(ctx.args).upper()) if not clan: return "Could not find a clan by that tag." clan_display_name = f"[{clan['tag']}] {clan['name']}" msg = [f"{clan_display_name} | Founded {clan['created_at']:%b %d, %Y}."] # get members privs from sql clan_members = await users_repo.fetch_many(clan_id=clan["id"]) for member in sorted(clan_members, key=lambda m: m["clan_priv"], reverse=True): priv_str = ("Member", "Officer", "Owner")[member["clan_priv"] - 1] msg.append(f"[{priv_str}] {member['name']}") return "\n".join(msg) @clan_commands.add(Privileges.UNRESTRICTED) async def clan_leave(ctx: Context) -> str | None: """Leaves the clan you're in.""" if not ctx.player.clan_id: return "You're not in a clan." elif ctx.player.clan_priv == ClanPrivileges.Owner: return "You must transfer your clan's ownership before leaving it. Alternatively, you can use !clan disband." clan = await clans_repo.fetch_one(id=ctx.player.clan_id) if not clan: return "You're not in a clan." clan_members = await users_repo.fetch_many(clan_id=clan["id"]) await users_repo.partial_update(ctx.player.id, clan_id=0, clan_priv=0) ctx.player.clan_id = None ctx.player.clan_priv = None clan_display_name = f"[{clan['tag']}] {clan['name']}" if not clan_members: # no members left, disband clan await clans_repo.delete_one(clan["id"]) # announce clan disbanding announce_chan = app.state.sessions.channels.get_by_name("#announce") if announce_chan: msg = f"\x01ACTION disbanded {clan_display_name}." announce_chan.send(msg, sender=ctx.player, to_self=True) return f"You have successfully left {clan_display_name}." # TODO: !clan inv, !clan join, !clan leave @clan_commands.add(Privileges.UNRESTRICTED, aliases=["l"]) async def clan_list(ctx: Context) -> str | None: """List all existing clans' information.""" if ctx.args: if len(ctx.args) != 1 or not ctx.args[0].isdecimal(): return "Invalid syntax: !clan list (page)" else: offset = 25 * int(ctx.args[0]) else: offset = 0 all_clans = await clans_repo.fetch_many(page=None, page_size=None) num_clans = len(all_clans) if offset >= num_clans: return "No clans found." msg = [f"bancho.py clans listing ({num_clans} total)."] for idx, clan in enumerate(all_clans, offset): clan_display_name = f"[{clan['tag']}] {clan['name']}" msg.append(f"{idx + 1}. {clan_display_name}") return "\n".join(msg) class CommandResponse(TypedDict): resp: str | None hidden: bool async def process_commands( player: Player, target: Channel | Player, msg: str, ) -> CommandResponse | None: # response is either a CommandResponse if we hit a command, # or simply False if we don't have any command hits. start_time = clock_ns() prefix_len = len(app.settings.COMMAND_PREFIX) trigger, *args = msg[prefix_len:].strip().split(" ") # case-insensitive triggers trigger = trigger.lower() # check if any command sets match. for cmd_set in command_sets: if trigger == cmd_set.trigger: if not args: args = ["help"] trigger, *args = args # get subcommand # case-insensitive triggers trigger = trigger.lower() commands = cmd_set.commands break else: # no set commands matched, check normal commands. commands = regular_commands for cmd in commands: if trigger in cmd.triggers and player.priv & cmd.priv == cmd.priv: # found matching trigger with sufficient privs try: res = await cmd.callback( Context( player=player, trigger=trigger, args=args, recipient=target, ), ) except Exception: # print exception info to the console, # but do not break the player's session. traceback.print_exc() res = "An exception occurred when running the command." if res is not None: # we have a message to return, include elapsed time elapsed = app.logging.magnitude_fmt_time(clock_ns() - start_time) return {"resp": f"{res} | Elapsed: {elapsed}", "hidden": cmd.hidden} else: # no message to return return {"resp": None, "hidden": False} return None