Ban system with durations, persistence, and login enforcement

Tempban Example

A full-featured ban command that supports permanent bans, timed bans with human-readable durations (e.g. 2h30m), optional reasons, and automatic enforcement on login.

Note: This example stores bans in memory. They will be lost on server restart. For production use, swap the bans dict for a Config or database.

Drop this file into plugins/PyJavaBridge/scripts/ and reload the server. Requires the humanfriendly pip package: pip install humanfriendly.

"""Ban users for customizable amounts of time."""

# humanfriendly provides format_timespan() which turns seconds into
# readable strings like "2 hours and 30 minutes".
from humanfriendly import format_timespan
from time import time as time_now
from bridge import *
from typing import Optional
import re

# ─── In-Memory Ban Storage ──────────────────────────────────────────
# Keys are player UUIDs (strings).
# Values are tuples: (expire_timestamp_or_None, reason_string).
# If the timestamp is None, the ban is permanent.
bans = {{}}


# ─── Duration Parser ───────────────────────────────────────────────
# Converts strings like "2h30m" or "7d" into total seconds.
# Supported units: s (seconds), m (minutes), h (hours), d (days),
# w (weeks), mo (months ≈ 30 days), y (years ≈ 365 days).

def parse_time(time: str) -> int:
    """Parse a human-readable time string (e.g. '2h30m') into seconds."""
    if not time:
        raise ValueError('Time string is required')

    # Map each unit suffix to its value in seconds.
    units = {{
        's': 1,
        'm': 60,
        'h': 3600,
        'd': 86400,
        'w': 604800,
        'mo': 2592000,   # ~30 days
        'y': 31536000,   # ~365 days
    }}

    # Regex captures pairs like ("2", "h") or ("30", "m").
    # 'mo' must come before single-char units so it matches first.
    pattern = re.compile(r'(\d+)(mo|[smhdwy])', re.IGNORECASE)
    total = 0
    time_str = time.strip().lower()
    idx = 0

    for match in pattern.finditer(time_str):
        # Make sure there are no gaps between matches (rejects "2x3h").
        if match.start() != idx:
            raise ValueError(f"Invalid time format: {{time}}")

        value = int(match.group(1))
        unit = match.group(2)

        if unit not in units:
            raise ValueError(f"Unknown time unit: {{unit}}")

        total += value * units[unit]
        idx = match.end()

    # If we didn't consume the entire string, the format is invalid.
    if idx != len(time_str):
        raise ValueError(f"Invalid time format: {{time}}")

    return total


# ─── Ban Command ────────────────────────────────────────────────────
# Usage:  /ban <player> [duration] [reason]
# - duration is optional: omit it for a permanent ban.
# - reason is optional: defaults to "Ban hammer has spoken!"
#
# The 't' and 'r' parameter names are short because players type them
# as positional arguments in chat.

@command('Ban someone for a specified time or permanently')
async def ban(event: Event, user: str, t: Optional[str] = None, r: Optional[str] = None):
    """Ban a player for a specified time or permanently."""
    global bans

    # Only server operators can ban players.
    if not event.player.is_op:
        event.player.send_message('No permission!')
        event.player.play_sound('block_note_block_bass')
        return

    # Look up the target player by name.
    target = Player(name=user)

    # Parse the duration (None means permanent).
    duration = parse_time(t) if t else None
    reason = r or "Ban hammer has spoken!"

    # Build the kick message the banned player will see.
    reason_text = (
        f"You have been {{'permanently ' if not duration else ''}}banned"
        f"{{f'\nFor {{format_timespan(duration)}}' if duration else ''}}"
        f"\nReason: {{reason}}"
    )

    # Kick the player immediately so they see the ban message.
    await target.kick(reason_text)

    # Store the ban: expire timestamp (or None for permanent) + reason.
    bans[target.uuid] = (time_now() + duration if duration else None, reason)

    # Confirm to the operator.
    event.player.send_message(
        f"{{user}} has been {{'permanently ' if not duration else ''}}banned "
        f"{{f'for {{format_timespan(duration)}}' if duration else ''}}"
    )


# ─── Unban Command ─────────────────────────────────────────────────
# Usage:  /unban <player>
# Removes the player from the ban dict so they can rejoin.

@command('Unban someone')
async def unban(event: Event, user: str):
    """Unban a previously banned player."""
    global bans

    if not event.player.is_op:
        event.player.send_message('No permission!')
        event.player.play_sound(Sound.from_name('BLOCK_NOTE_BLOCK_BASS'))
        return

    target = Player(name=user)

    # Check that the player is actually banned before trying to remove.
    if target.uuid not in bans:
        event.player.send_message("That user is not banned")
        event.player.play_sound(Sound.from_name('BLOCK_NOTE_BLOCK_BASS'))
        return

    # Remove the ban entry.
    bans.pop(target.uuid)

    event.player.send_message(f"{{user}} has been unbanned.")


# ─── Login Enforcement ─────────────────────────────────────────────
# When a banned player tries to join, we check if their ban has
# expired.  If it has, we silently remove it and let them in.
# If it hasn't, we kick them again with the remaining time.

@event
async def player_join(event: Event):
    """Kick banned players on join if their ban is still active."""
    global bans
    uuid = event.player.uuid

    # Player isn't banned — nothing to do.
    if uuid not in bans:
        return

    time, reason = bans[uuid]

    # For timed bans, compute seconds remaining.
    if time:
        time -= time_now()

    # If the ban has expired, clean it up and let the player join.
    if time and time <= 0:
        bans.pop(uuid)
        return

    # Ban is still active — kick the player with an updated message.
    event.player.kick(
        f"You have been {{'permanently ' if not time else ''}}banned"
        f"{{f'\nFor {{format_timespan(time)}}' if time else ''}}"
        f"\nReason: {{reason}}"
    )