Files
Iridium/iridium/join_tip.py
T

243 lines
6.2 KiB
Python

import re
import threading
from datetime import datetime
from typing import List, Optional, Tuple
from mcdreforged.api.all import *
from iridium.config import config
from iridium.utils import sanitize_name
from iridium.version_util import tr
LINK_WITH_TEXT_RE = re.compile(r"\{link:(?P<text>[^|}]+)\|(?P<url>[^}]+)\}")
LINK_URL_ONLY_RE = re.compile(r"\{link:(?P<url>[^}|]+)\}")
_COLOR_MAP = {
"0": RColor.black,
"1": RColor.dark_blue,
"2": RColor.dark_green,
"3": RColor.dark_aqua,
"4": RColor.dark_red,
"5": RColor.dark_purple,
"6": RColor.gold,
"7": RColor.gray,
"8": RColor.dark_gray,
"9": RColor.blue,
"a": RColor.green,
"b": RColor.aqua,
"c": RColor.red,
"d": RColor.light_purple,
"e": RColor.yellow,
"f": RColor.white,
}
_STYLE_MAP = {
"k": RStyle.obfuscated,
"l": RStyle.bold,
"m": RStyle.strikethrough,
"n": RStyle.underlined,
"o": RStyle.italic,
}
def server_days() -> Optional[int]:
raw = (config.motd_start_day or "").strip()
if not raw:
return None
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
start = datetime.strptime(raw, fmt)
except ValueError:
continue
return max(0, (datetime.now() - start).days) + 1
return None
def online_hint(server: ServerInterface) -> str:
try:
info = server.get_server_information()
if info is not None and getattr(info, "player_count", None) is not None:
return str(info.player_count)
except Exception:
pass
return "?"
def replace_placeholders(line: str, player: str, server: ServerInterface) -> str:
days = server_days()
days_text = str(days) if days is not None else "?"
# Sanitize player name before it can re-enter the {link:...} parser or § codes
safe_player = sanitize_name(player) or "Player"
return (
line.replace("{player}", safe_player)
.replace("{days}", days_text)
.replace("{day}", days_text)
.replace("{online}", online_hint(server))
)
def parse_colored_pieces(segment: str) -> List[RText]:
"""Split a string with § color codes into RText pieces."""
pieces: List[RText] = []
color: Optional[RColor] = None
styles: List[RStyle] = []
buf = ""
def flush() -> None:
nonlocal buf
if not buf:
return
pieces.append(RText(buf, color=color, styles=list(styles) if styles else None))
buf = ""
i = 0
n = len(segment)
while i < n:
if segment[i] == "§" and i + 1 < n:
code = segment[i + 1].lower()
i += 2
if code == "r":
flush()
color = None
styles = []
continue
if code in _COLOR_MAP:
flush()
color = _COLOR_MAP[code]
styles = []
continue
if code in _STYLE_MAP:
flush()
style = _STYLE_MAP[code]
if style not in styles:
styles.append(style)
continue
buf += f"§{code}"
continue
buf += segment[i]
i += 1
flush()
return pieces or [RText(segment or "")]
def _apply_click(piece: RText, url: str) -> RText:
return piece.c(RAction.open_url, url).h(RText(url, RColor.gray))
def render_motd_line(line: str, player: str, server: ServerInterface) -> List[RText]:
raw = replace_placeholders(line, player, server)
# Tokenize: plain text | (text, url)
tokens: List[Tuple[str, str, Optional[str]]] = []
last = 0
def push_text(chunk: str) -> None:
if chunk:
tokens.append(("text", chunk, None))
for match in LINK_WITH_TEXT_RE.finditer(raw):
push_text(raw[last : match.start()])
tokens.append(("link", match.group("text"), match.group("url").strip()))
last = match.end()
# Remaining part may still contain {link:url}
rest = raw[last:]
pos = 0
for match in LINK_URL_ONLY_RE.finditer(rest):
push_text(rest[pos : match.start()])
url = match.group("url").strip()
tokens.append(("link", url, url))
pos = match.end()
push_text(rest[pos:])
if not tokens:
tokens.append(("text", raw, None))
out: List[RText] = []
for kind, text, url in tokens:
if kind == "text":
out.extend(parse_colored_pieces(text))
continue
assert url is not None
if not url.lower().startswith(("http://", "https://")):
out.extend(parse_colored_pieces(text))
continue
colored = parse_colored_pieces(text)
if not colored:
colored = [RText(text, RColor.aqua)]
for piece in colored:
out.append(_apply_click(piece, url))
return out
def build_motd(player: str, server: ServerInterface) -> Optional[RTextList]:
if not config.motd_enabled:
return None
lines = list(config.motd_lines or [])
if not lines:
return None
parts: List[RText] = []
for i, line in enumerate(lines):
if i:
parts.append(RText("\n"))
parts.extend(render_motd_line(line, player, server))
# Only auto-append the day footer when no line already uses {days}/{day}
days = server_days()
if days is not None and not any(("{days}" in ln or "{day}" in ln) for ln in lines):
parts.append(RText("\n"))
parts.append(RText(tr("motd_days", days), RColor.gray))
return RTextList(*parts)
def command_line(cmd: str, desc: str) -> List[RText]:
return [
RText(cmd, RColor.aqua, styles=[RStyle.underlined])
.c(RAction.suggest_command, cmd)
.h(RText(tr("join_tip_click"), RColor.gray)),
RText(" - ", RColor.dark_gray),
RText(desc, RColor.white),
]
def build_command_tip() -> RTextList:
parts: List[RText] = [
RText("[Iridium] ", RColor.gray),
RText(tr("join_tip_header"), RColor.gold),
]
for cmd, desc in (
("!!share", tr("share_help")),
("!!hat", tr("hat_help")),
("!!head", tr("head_help_short")),
("!!c 1+1", tr("calc_help")),
):
parts.append(RText("\n"))
parts.extend(command_line(cmd, desc))
return RTextList(*parts)
def send_join_message(server: ServerInterface, player: str) -> None:
try:
motd = build_motd(player, server)
if motd is not None:
server.tell(player, motd)
if config.join_tip:
server.tell(player, build_command_tip())
except Exception as e:
server.logger.debug(f"join message failed for {player}: {e}")
def on_player_joined(server: PluginServerInterface, player: str, info: Info) -> None:
if not config.motd_enabled and not config.join_tip:
return
delay = max(0.0, float(config.join_tip_delay_seconds))
if delay <= 0:
send_join_message(server, player)
return
timer = threading.Timer(delay, lambda: send_join_message(server, player))
timer.daemon = True
timer.start()
def register(server: PluginServerInterface) -> None:
# MCDR event name is PLAYER_JOINED (not PLAYER_JOIN)
server.register_event_listener(MCDRPluginEvents.PLAYER_JOINED, on_player_joined)