发布 Iridium 26.9.1
MCDR plugin: item share, hat, player head, calculator, join MOTD. All rights reserved. Author: 云镜之端/SKYMirror
This commit is contained in:
commit
09c2392b99
18 files changed
+1796
No files matched your search
@@ -0,0 +1,39 @@
|
||||
"""Iridium — item share / hat / head / calculator for MCDReforged.
|
||||
|
||||
Copyright (c) 2026 云镜之端/SKYMirror. All Rights Reserved.
|
||||
"""
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
import iridium.calculator as calculator
|
||||
import iridium.hat as hat
|
||||
import iridium.head as head
|
||||
import iridium.join_tip as join_tip
|
||||
import iridium.share as share
|
||||
from iridium.config import CONFIG_FILE, Config, apply_config
|
||||
from iridium.version_util import tr
|
||||
|
||||
|
||||
def on_load(server: PluginServerInterface, prev_module) -> None:
|
||||
loaded = server.load_config_simple(
|
||||
CONFIG_FILE,
|
||||
target_class=Config,
|
||||
in_data_folder=False,
|
||||
)
|
||||
apply_config(loaded)
|
||||
server.logger.info(tr("loaded"))
|
||||
|
||||
share.register(server)
|
||||
hat.register(server)
|
||||
head.register(server)
|
||||
calculator.register(server)
|
||||
join_tip.register(server)
|
||||
|
||||
server.register_event_listener(MCDRPluginEvents.GENERAL_INFO, share.handle_info)
|
||||
|
||||
ver_hint = loaded.force_version or "auto"
|
||||
server.logger.info(tr("version_mode", ver_hint))
|
||||
|
||||
|
||||
def on_unload(server: PluginServerInterface) -> None:
|
||||
server.logger.info(tr("unloaded"))
|
||||
@@ -0,0 +1,112 @@
|
||||
import ast
|
||||
import operator
|
||||
import re
|
||||
from typing import Any, Union
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.config import config
|
||||
from iridium.utils import check_permission
|
||||
from iridium.version_util import tr
|
||||
|
||||
Number = Union[int, float]
|
||||
|
||||
_BIN_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
}
|
||||
_UNARY_OPS = {
|
||||
ast.UAdd: operator.pos,
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
MAX_EXPR_LEN = 200
|
||||
MAX_POW = 64
|
||||
|
||||
|
||||
class CalcError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _eval_node(node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval_node(node.body)
|
||||
# ast.Constant covers int/float on Python 3.8+; do not touch ast.Num (removed in 3.12)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
|
||||
return _UNARY_OPS[type(node.op)](_eval_node(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
|
||||
left = _eval_node(node.left)
|
||||
right = _eval_node(node.right)
|
||||
if isinstance(node.op, ast.Pow):
|
||||
if isinstance(right, (int, float)) and abs(right) > MAX_POW:
|
||||
raise CalcError("power too large")
|
||||
return _BIN_OPS[type(node.op)](left, right)
|
||||
raise CalcError("unsupported expression")
|
||||
|
||||
|
||||
def safe_eval(expression: str) -> Number:
|
||||
expr = expression.strip()
|
||||
if not expr:
|
||||
raise CalcError("empty")
|
||||
if len(expr) > MAX_EXPR_LEN:
|
||||
raise CalcError("expression too long")
|
||||
if not re.fullmatch(r"[0-9+\-*/%().\s^]+", expr):
|
||||
raise CalcError("illegal character")
|
||||
expr = expr.replace("^", "**")
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
except SyntaxError as e:
|
||||
raise CalcError(str(e))
|
||||
return _eval_node(tree)
|
||||
|
||||
|
||||
def _format_result(value: Number) -> str:
|
||||
if isinstance(value, float):
|
||||
if value.is_integer() and abs(value) < 1e15:
|
||||
return str(int(value))
|
||||
return f"{value:.12g}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def do_calc(server: PluginServerInterface, src: CommandSource, context: dict) -> None:
|
||||
if not check_permission(src, config.permission):
|
||||
return
|
||||
expression = context.get("expression", "").strip()
|
||||
try:
|
||||
result = safe_eval(expression)
|
||||
except CalcError as e:
|
||||
src.reply(RText(tr("calc_error", expression, str(e)), RColor.red))
|
||||
return
|
||||
except ZeroDivisionError:
|
||||
src.reply(RText(tr("calc_div_zero"), RColor.red))
|
||||
return
|
||||
except Exception as e:
|
||||
src.reply(RText(tr("calc_error", expression, str(e)), RColor.red))
|
||||
return
|
||||
|
||||
out = _format_result(result)
|
||||
# Private reply to avoid flooding public chat
|
||||
src.reply(
|
||||
RTextList(
|
||||
RText("[Iridium] ", RColor.gray),
|
||||
RText(expression, RColor.yellow),
|
||||
RText(" = ", RColor.gray),
|
||||
RText(out, RColor.aqua, styles=RStyle.bold),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register(server: PluginServerInterface) -> None:
|
||||
builder = SimpleCommandBuilder()
|
||||
builder.command("!!c", lambda src: src.reply(RText(tr("calc_usage"), RColor.yellow)))
|
||||
builder.command("!!c <expression>", do_calc)
|
||||
builder.arg("expression", GreedyText)
|
||||
builder.register(server)
|
||||
server.register_help_message("!!c <expression>", tr("calc_help"))
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import List
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
|
||||
class Config(Serializable):
|
||||
# 使用 !!share / !!hat / !!head / !!c 所需的最低权限等级
|
||||
permission: int = 1
|
||||
# !!head 冷却时间(秒),0 表示不限制
|
||||
head_cooldown_seconds: int = 60
|
||||
# !!hat 冷却时间(秒),0 表示不限制
|
||||
hat_cooldown_seconds: int = 3
|
||||
# !!share 是否播放提示音
|
||||
share_sound: bool = True
|
||||
# 强制指定 MC 版本号(如 "1.12.2"),留空则自动检测
|
||||
force_version: str = ""
|
||||
# 玩家加入时是否提示可用命令
|
||||
join_tip: bool = True
|
||||
# 加入提示延迟(秒),避免和进服消息挤在一起
|
||||
join_tip_delay_seconds: float = 1.5
|
||||
# 是否发送进服 MOTD(开服天数 + 自定义行)
|
||||
motd_enabled: bool = True
|
||||
# 开服日期,格式 YYYY-MM-DD,用于计算开服天数;留空则不显示天数
|
||||
motd_start_day: str = ""
|
||||
# MOTD 文本行。支持:
|
||||
# {player} 玩家名
|
||||
# {days} 开服天数
|
||||
# {online} 在线人数(拿不到时显示 ?)
|
||||
# {link:显示文字|https://url} 可点击链接
|
||||
# {link:https://url} 链接,显示为完整 URL
|
||||
# § 颜色代码
|
||||
motd_lines: List[str] = [
|
||||
"§6欢迎 §e{player}§6 加入服务器!",
|
||||
"§7服务器已开服 §b{days}§7 天",
|
||||
"§7官网: {link:§b点击打开官网|https://www.skymirror.top}",
|
||||
"§7QQ群: §f985402607",
|
||||
]
|
||||
|
||||
|
||||
CONFIG_FILE = "config/iridium.json"
|
||||
|
||||
# 模块级单例;on_load 时原地更新字段,避免各模块持有过期引用
|
||||
config = Config()
|
||||
|
||||
|
||||
def apply_config(new_cfg: Config) -> None:
|
||||
config.permission = new_cfg.permission
|
||||
config.head_cooldown_seconds = new_cfg.head_cooldown_seconds
|
||||
config.hat_cooldown_seconds = new_cfg.hat_cooldown_seconds
|
||||
config.share_sound = new_cfg.share_sound
|
||||
config.force_version = new_cfg.force_version
|
||||
config.join_tip = new_cfg.join_tip
|
||||
config.join_tip_delay_seconds = new_cfg.join_tip_delay_seconds
|
||||
config.motd_enabled = new_cfg.motd_enabled
|
||||
config.motd_start_day = new_cfg.motd_start_day
|
||||
config.motd_lines = list(new_cfg.motd_lines)
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.config import config
|
||||
from iridium.utils import (
|
||||
check_cooldown,
|
||||
check_permission,
|
||||
looks_like_item_snbt,
|
||||
mark_cooldown,
|
||||
parse_selected_item,
|
||||
require_player,
|
||||
rcon_query,
|
||||
safe_execute,
|
||||
sanitize_name,
|
||||
strip_data_prefix,
|
||||
)
|
||||
from iridium.version_util import is_at_least, require_version, tr
|
||||
|
||||
AIR_RE = re.compile(r'id\s*:\s*"(minecraft:)?air"')
|
||||
|
||||
|
||||
def _is_air(snbt: Optional[str]) -> bool:
|
||||
if snbt is None:
|
||||
return True
|
||||
stripped = snbt.strip()
|
||||
return stripped in ("{}", "", "[]") or bool(AIR_RE.search(stripped))
|
||||
|
||||
|
||||
def _can_swap(version: Version) -> bool:
|
||||
return is_at_least(version, "1.8")
|
||||
|
||||
|
||||
def _entitydata_query(player: str) -> str:
|
||||
return f"entitydata {sanitize_name(player)} {{}}"
|
||||
|
||||
|
||||
def _selected_query(player: str, version: Version) -> str:
|
||||
name = sanitize_name(player)
|
||||
if is_at_least(version, "1.13"):
|
||||
return f"data get entity {name} SelectedItem"
|
||||
return _entitydata_query(player)
|
||||
|
||||
|
||||
def _head_query(player: str, version: Version) -> str:
|
||||
name = sanitize_name(player)
|
||||
if is_at_least(version, "1.13"):
|
||||
return f"data get entity {name} equipment.head"
|
||||
return _entitydata_query(player)
|
||||
|
||||
|
||||
def _head_slot(version: Version) -> str:
|
||||
if is_at_least(version, "1.13"):
|
||||
return "armor.head"
|
||||
return "slot.armor.head"
|
||||
|
||||
|
||||
def _hand_slot(version: Version, hotbar_index: int = 0) -> str:
|
||||
if is_at_least(version, "1.13"):
|
||||
return "weapon.mainhand"
|
||||
if is_at_least(version, "1.9"):
|
||||
return "slot.weapon"
|
||||
return f"slot.hotbar.{hotbar_index}"
|
||||
|
||||
|
||||
def _extract_selected_slot(payload: str) -> int:
|
||||
body = strip_data_prefix(payload)
|
||||
m = re.search(r"SelectedItemSlot\s*:\s*(\d+)", body)
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def _extract_head_item(payload: str, version: Version) -> Optional[str]:
|
||||
body = strip_data_prefix(payload)
|
||||
if not body or not looks_like_item_snbt(body):
|
||||
return None
|
||||
if is_at_least(version, "1.13"):
|
||||
body = body.strip()
|
||||
if _is_air(body):
|
||||
return None
|
||||
return body
|
||||
m = re.search(r"Inventory\s*:", body)
|
||||
if m is None:
|
||||
return None
|
||||
i = m.end()
|
||||
while i < len(body) and body[i].isspace():
|
||||
i += 1
|
||||
if i >= len(body) or body[i] != "[":
|
||||
return None
|
||||
depth = 0
|
||||
inv = ""
|
||||
for j in range(i, len(body)):
|
||||
if body[j] == "[":
|
||||
depth += 1
|
||||
elif body[j] == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
inv = body[i : j + 1]
|
||||
break
|
||||
if not inv:
|
||||
return None
|
||||
for sm in re.finditer(r"\{(?:[^{}]|\{[^{}]*\})*\}", inv):
|
||||
if re.search(r"Slot\s*:\s*103b?", sm.group(0)):
|
||||
item = sm.group(0)
|
||||
return None if _is_air(item) else item
|
||||
return None
|
||||
|
||||
|
||||
def _split_snbt_compound(snbt: str, key: str) -> Optional[str]:
|
||||
m = re.search(rf"(?:^|[{{,\s]){re.escape(key)}\s*:\s*(\{{)", snbt)
|
||||
if m is None:
|
||||
return None
|
||||
start = m.start(1)
|
||||
depth = 0
|
||||
for i in range(start, len(snbt)):
|
||||
if snbt[i] == "{":
|
||||
depth += 1
|
||||
elif snbt[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return snbt[start : i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _components_to_command_inner(components_compound: str) -> str:
|
||||
"""
|
||||
Convert a data-get components compound into item-command component list form.
|
||||
|
||||
data get (1.20.5+): components:{"minecraft:custom_name":{...}, "minecraft:damage":1}
|
||||
item replace form: minecraft:item[minecraft:custom_name={...},minecraft:damage=1]
|
||||
"""
|
||||
inner = components_compound.strip()[1:-1].strip()
|
||||
if not inner:
|
||||
return ""
|
||||
# Top-level key:value pairs. Values may be compounds/lists/scalars.
|
||||
parts = []
|
||||
i = 0
|
||||
n = len(inner)
|
||||
while i < n:
|
||||
while i < n and inner[i] in ", \t\n":
|
||||
i += 1
|
||||
if i >= n:
|
||||
break
|
||||
# key
|
||||
key_m = re.match(r'"([^"]+)"', inner[i:])
|
||||
if key_m is None:
|
||||
key_m = re.match(r"([A-Za-z0-9_.:\-]+)", inner[i:])
|
||||
if key_m is None:
|
||||
i += 1
|
||||
continue
|
||||
key = key_m.group(1)
|
||||
i += key_m.end()
|
||||
while i < n and inner[i].isspace():
|
||||
i += 1
|
||||
if i >= n or inner[i] != ":":
|
||||
continue
|
||||
i += 1
|
||||
while i < n and inner[i].isspace():
|
||||
i += 1
|
||||
# value
|
||||
if i >= n:
|
||||
break
|
||||
if inner[i] == "{":
|
||||
depth = 0
|
||||
start = i
|
||||
while i < n:
|
||||
if inner[i] == "{":
|
||||
depth += 1
|
||||
elif inner[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
value = inner[start:i]
|
||||
elif inner[i] == "[":
|
||||
depth = 0
|
||||
start = i
|
||||
while i < n:
|
||||
if inner[i] == "[":
|
||||
depth += 1
|
||||
elif inner[i] == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
value = inner[start:i]
|
||||
elif inner[i] == '"':
|
||||
start = i
|
||||
i += 1
|
||||
while i < n:
|
||||
if inner[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if inner[i] == '"':
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
value = inner[start:i]
|
||||
else:
|
||||
start = i
|
||||
while i < n and inner[i] not in ",":
|
||||
i += 1
|
||||
value = inner[start:i].strip()
|
||||
parts.append(f"{key}={value}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _item_to_command_form(snbt: str, version: Version) -> Tuple[str, Optional[int]]:
|
||||
id_m = re.search(r'id\s*:\s*"([^"]+)"', snbt)
|
||||
if id_m is None:
|
||||
return "minecraft:air", 1
|
||||
item_id = id_m.group(1)
|
||||
cnt: Optional[int] = None
|
||||
if is_at_least(version, "1.20.5"):
|
||||
c_m = re.search(r"\bcount\s*:\s*(\d+)", snbt)
|
||||
cnt = int(c_m.group(1)) if c_m else 1
|
||||
comps = _split_snbt_compound(snbt, "components")
|
||||
if comps and comps != "{}":
|
||||
inner = _components_to_command_inner(comps)
|
||||
if inner:
|
||||
return f"{item_id}[{inner}]", cnt
|
||||
return item_id, cnt
|
||||
|
||||
c_m = re.search(r"Count\s*:\s*(\d+)", snbt)
|
||||
cnt = int(c_m.group(1)) if c_m else 1
|
||||
tag = _split_snbt_compound(snbt, "tag")
|
||||
if tag and tag != "{}":
|
||||
return f"{item_id}{tag}", cnt
|
||||
return item_id, cnt
|
||||
|
||||
|
||||
def _replace_with(player: str, slot: str, item_snbt: str, version: Version) -> str:
|
||||
name = sanitize_name(player)
|
||||
part, count = _item_to_command_form(item_snbt, version)
|
||||
if part.endswith(":air") or part == "air":
|
||||
part = "air"
|
||||
count = None
|
||||
if is_at_least(version, "1.17"):
|
||||
cmd = f"item replace entity {name} {slot} with {part}"
|
||||
if count is not None and count != 1:
|
||||
cmd += f" {count}"
|
||||
return cmd
|
||||
cmd = f"replaceitem entity {name} {slot} {part}"
|
||||
if count is not None and count != 1:
|
||||
cmd += f" {count}"
|
||||
return cmd
|
||||
|
||||
|
||||
def _extract_item_payload(payload: str, version: Version) -> Optional[str]:
|
||||
body = strip_data_prefix(payload)
|
||||
if not looks_like_item_snbt(body):
|
||||
return None
|
||||
if is_at_least(version, "1.13"):
|
||||
return body.strip()
|
||||
return parse_selected_item(body)
|
||||
|
||||
|
||||
def _do_swap(server: PluginServerInterface, src: CommandSource, player: str, version: Version) -> bool:
|
||||
if not is_at_least(version, "1.13"):
|
||||
# Single entitydata dump contains both SelectedItem and head Inventory slot
|
||||
dump = rcon_query(server, _entitydata_query(player))
|
||||
if dump is None:
|
||||
src.reply(RText(tr("hat_query_failed"), RColor.red))
|
||||
return False
|
||||
hotbar_index = _extract_selected_slot(dump)
|
||||
hand = _extract_item_payload(dump, version)
|
||||
if hand is None or _is_air(hand):
|
||||
src.reply(RText(tr("hand_is_empty"), RColor.red))
|
||||
return False
|
||||
head = _extract_head_item(dump, version)
|
||||
else:
|
||||
hand_raw = rcon_query(server, _selected_query(player, version))
|
||||
if hand_raw is None:
|
||||
src.reply(RText(tr("hat_query_failed"), RColor.red))
|
||||
return False
|
||||
hotbar_index = 0
|
||||
hand = _extract_item_payload(hand_raw, version)
|
||||
if hand is None or _is_air(hand):
|
||||
src.reply(RText(tr("hand_is_empty"), RColor.red))
|
||||
return False
|
||||
head = None
|
||||
head_raw = rcon_query(server, _head_query(player, version))
|
||||
if head_raw is not None:
|
||||
head = _extract_head_item(head_raw, version)
|
||||
|
||||
head_slot = _head_slot(version)
|
||||
hand_slot = _hand_slot(version, hotbar_index)
|
||||
name = sanitize_name(player)
|
||||
|
||||
if is_at_least(version, "1.17"):
|
||||
safe_execute(
|
||||
server,
|
||||
f"item replace entity {name} {head_slot} from entity {name} weapon.mainhand",
|
||||
)
|
||||
if head and not _is_air(head):
|
||||
safe_execute(server, _replace_with(player, hand_slot, head, version))
|
||||
else:
|
||||
safe_execute(server, f"item replace entity {name} {hand_slot} with air")
|
||||
else:
|
||||
safe_execute(server, _replace_with(player, head_slot, hand, version))
|
||||
if head and not _is_air(head):
|
||||
safe_execute(server, _replace_with(player, hand_slot, head, version))
|
||||
else:
|
||||
safe_execute(server, _replace_with(player, hand_slot, "minecraft:air", version))
|
||||
|
||||
src.reply(RText(tr("hat_success"), RColor.green))
|
||||
return True
|
||||
|
||||
|
||||
def do_hat(server: PluginServerInterface, src: CommandSource) -> None:
|
||||
player = require_player(src)
|
||||
if player is None:
|
||||
return
|
||||
if not check_permission(src, config.permission):
|
||||
return
|
||||
version = require_version(server, src)
|
||||
if version is None:
|
||||
return
|
||||
if not _can_swap(version):
|
||||
src.reply(RText(tr("hat_unsupported_version", str(version)), RColor.red))
|
||||
return
|
||||
if not server.is_rcon_running():
|
||||
src.reply(RText(tr("hat_need_rcon"), RColor.red))
|
||||
return
|
||||
if not check_cooldown(src, player, "hat", config.hat_cooldown_seconds):
|
||||
return
|
||||
|
||||
if _do_swap(server, src, player, version):
|
||||
mark_cooldown(player, "hat")
|
||||
|
||||
|
||||
def register(server: PluginServerInterface) -> None:
|
||||
server.register_command(Literal("!!hat").runs(lambda src: do_hat(server, src)))
|
||||
server.register_help_message("!!hat", tr("hat_help"))
|
||||
@@ -0,0 +1,52 @@
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.config import config
|
||||
from iridium.utils import (
|
||||
check_cooldown,
|
||||
check_permission,
|
||||
mark_cooldown,
|
||||
require_player,
|
||||
safe_execute,
|
||||
sanitize_name,
|
||||
)
|
||||
from iridium.version_util import is_at_least, require_version, tr
|
||||
|
||||
|
||||
def _give_head_command(player: str, target: str, version: Version) -> str:
|
||||
# Defense in depth: never interpolate an unsanitized name
|
||||
safe_player = sanitize_name(player) or "Steve"
|
||||
safe_target = sanitize_name(target) or safe_player
|
||||
if is_at_least(version, "1.20.5"):
|
||||
return f'give {safe_player} minecraft:player_head[minecraft:profile="{safe_target}"] 1'
|
||||
if is_at_least(version, "1.13"):
|
||||
return f'give {safe_player} minecraft:player_head{{SkullOwner:"{safe_target}"}} 1'
|
||||
return f'give {safe_player} minecraft:skull 1 3 {{SkullOwner:"{safe_target}"}}'
|
||||
|
||||
|
||||
def do_head(server: PluginServerInterface, src: CommandSource, context: dict) -> None:
|
||||
player = require_player(src)
|
||||
if player is None:
|
||||
return
|
||||
if not check_permission(src, config.permission):
|
||||
return
|
||||
version = require_version(server, src)
|
||||
if version is None:
|
||||
return
|
||||
|
||||
target = context.get("player_name") or player
|
||||
safe_target = sanitize_name(target) or sanitize_name(player) or player
|
||||
if not check_cooldown(src, player, "head", config.head_cooldown_seconds):
|
||||
return
|
||||
|
||||
safe_execute(server, _give_head_command(player, safe_target, version))
|
||||
mark_cooldown(player, "head")
|
||||
src.reply(RText(tr("head_success", safe_target), RColor.green))
|
||||
|
||||
|
||||
def register(server: PluginServerInterface) -> None:
|
||||
builder = SimpleCommandBuilder()
|
||||
builder.command("!!head", lambda src: do_head(server, src, {}))
|
||||
builder.command("!!head <player_name>", lambda src, ctx: do_head(server, src, ctx))
|
||||
builder.arg("player_name", Text)
|
||||
builder.register(server)
|
||||
server.register_help_message("!!head [player]", tr("head_help", config.head_cooldown_seconds))
|
||||
@@ -0,0 +1,242 @@
|
||||
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] = [
|
||||
RText(tr("motd_header"), RColor.gold, styles=[RStyle.bold])
|
||||
]
|
||||
for line in lines:
|
||||
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:
|
||||
server.register_event_listener(MCDRPluginEvents.PLAYER_JOIN, on_player_joined)
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.config import config
|
||||
from iridium.utils import (
|
||||
check_permission,
|
||||
looks_like_item_snbt,
|
||||
parse_selected_item,
|
||||
require_player,
|
||||
rcon_query,
|
||||
safe_execute,
|
||||
sanitize_name,
|
||||
strip_data_prefix,
|
||||
)
|
||||
from iridium.version_util import is_at_least, require_version, tr
|
||||
|
||||
# pending share requests: player -> (src, version, expire_ts)
|
||||
_pending: Dict[str, Tuple[CommandSource, Version, float]] = {}
|
||||
_pending_lock = threading.Lock()
|
||||
|
||||
PENDING_TTL = 5.0
|
||||
# Keep tellraw well under typical command-length limits
|
||||
MAX_SNBT_LEN = 20000
|
||||
|
||||
PLAYER_DATA_RE = re.compile(
|
||||
r"^(?P<name>\S+) has the following entity data: (?P<data>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _can_read_selected_item(version: Version) -> bool:
|
||||
return is_at_least(version, "1.8")
|
||||
|
||||
|
||||
def _query_command(player: str, version: Version) -> str:
|
||||
name = sanitize_name(player)
|
||||
if is_at_least(version, "1.13"):
|
||||
return f"data get entity {name} SelectedItem"
|
||||
return f"entitydata {name} {{}}"
|
||||
|
||||
|
||||
def _sound_command(version: Version) -> str:
|
||||
if is_at_least(version, "1.9"):
|
||||
return "playsound minecraft:entity.arrow.hit_player master @a"
|
||||
return "playsound random.successful_hit @a"
|
||||
|
||||
|
||||
def _is_air(snbt: Optional[str]) -> bool:
|
||||
if snbt is None:
|
||||
return True
|
||||
stripped = snbt.strip()
|
||||
return stripped in ("{}", "", "[]") or bool(
|
||||
re.search(r'id\s*:\s*"(minecraft:)?air"', stripped)
|
||||
)
|
||||
|
||||
|
||||
def _json_str(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)[1:-1]
|
||||
|
||||
|
||||
def _build_tellraw(player: str, item_snbt: str, version: Version) -> str:
|
||||
# Truncate pathological NBT (huge books etc.) so the command stays valid
|
||||
if len(item_snbt) > MAX_SNBT_LEN:
|
||||
item_snbt = item_snbt[:MAX_SNBT_LEN] + "..."
|
||||
snbt = _json_str(item_snbt)
|
||||
player_js = _json_str(sanitize_name(player) or player)
|
||||
|
||||
if is_at_least(version, "1.21.5"):
|
||||
hover = '{"action":"show_item","contents":"' + snbt + '"}'
|
||||
else:
|
||||
hover = '{"action":"show_item","value":"' + snbt + '"}'
|
||||
|
||||
if is_at_least(version, "1.16"):
|
||||
click = '{"action":"copy_to_clipboard","value":"' + snbt + '"}'
|
||||
click_label = _json_str(tr("click_copy"))
|
||||
else:
|
||||
# pre-1.16 has no copy_to_clipboard; suggest the version-appropriate query cmd
|
||||
safe_player = sanitize_name(player)
|
||||
if is_at_least(version, "1.13"):
|
||||
suggest = f"/data get entity {safe_player} SelectedItem"
|
||||
else:
|
||||
suggest = f"/entitydata {safe_player} {{}}"
|
||||
click = '{"action":"suggest_command","value":"' + _json_str(suggest) + '"}'
|
||||
click_label = _json_str(tr("click_suggest"))
|
||||
|
||||
id_match = re.search(r'id\s*:\s*"([^"]+)"', item_snbt)
|
||||
item_id = id_match.group(1) if id_match else "?"
|
||||
name_match = re.search(r'"text"\s*:\s*"([^"]+)"', item_snbt)
|
||||
display = name_match.group(1) if name_match else item_id
|
||||
display_js = _json_str(display)
|
||||
|
||||
json_text = (
|
||||
'[{"text":"[Iridium] ","color":"gray"},'
|
||||
'{"text":"' + player_js + '","color":"yellow"},'
|
||||
'{"text":" ' + _json_str(tr("share_showing")) + ' "},'
|
||||
'{"text":"' + display_js + '","color":"aqua","hoverEvent":' + hover + "},"
|
||||
'{"text":" [' + click_label + ']","color":"aqua","bold":true,'
|
||||
'"underlined":true,"hoverEvent":' + hover + ',"clickEvent":' + click + "}]"
|
||||
)
|
||||
return f"tellraw @a {json_text}"
|
||||
|
||||
|
||||
def _send_share(
|
||||
server: PluginServerInterface,
|
||||
src: CommandSource,
|
||||
player: str,
|
||||
item_snbt: str,
|
||||
version: Version,
|
||||
) -> bool:
|
||||
if _is_air(item_snbt) or not looks_like_item_snbt(item_snbt):
|
||||
src.reply(RText(tr("hand_is_empty"), RColor.red))
|
||||
return False
|
||||
safe_execute(server, _build_tellraw(player, item_snbt, version))
|
||||
if config.share_sound:
|
||||
safe_execute(server, _sound_command(version))
|
||||
src.reply(RText(tr("share_done"), RColor.green))
|
||||
return True
|
||||
|
||||
|
||||
def _extract_item(payload: str, version: Version) -> Optional[str]:
|
||||
"""Normalize an entity-data payload into a single-item SNBT string, or None."""
|
||||
if not payload:
|
||||
return None
|
||||
body = strip_data_prefix(payload)
|
||||
if not looks_like_item_snbt(body):
|
||||
return None
|
||||
if is_at_least(version, "1.13"):
|
||||
return body.strip()
|
||||
return parse_selected_item(body)
|
||||
|
||||
|
||||
def _purge_expired_pending() -> None:
|
||||
now = time.time()
|
||||
expired = [p for p, (_, _, exp) in _pending.items() if now >= exp]
|
||||
for p in expired:
|
||||
_pending.pop(p, None)
|
||||
|
||||
|
||||
def do_share(server: PluginServerInterface, src: CommandSource) -> None:
|
||||
player = require_player(src)
|
||||
if player is None:
|
||||
return
|
||||
if not check_permission(src, config.permission):
|
||||
return
|
||||
version = require_version(server, src)
|
||||
if version is None:
|
||||
return
|
||||
if not _can_read_selected_item(version):
|
||||
src.reply(RText(tr("share_unsupported_version", str(version)), RColor.red))
|
||||
return
|
||||
|
||||
query = _query_command(player, version)
|
||||
raw = rcon_query(server, query)
|
||||
if raw is not None:
|
||||
item = _extract_item(raw, version)
|
||||
if item is None:
|
||||
src.reply(RText(tr("hand_is_empty"), RColor.red))
|
||||
return
|
||||
_send_share(server, src, player, item, version)
|
||||
return
|
||||
|
||||
with _pending_lock:
|
||||
_purge_expired_pending()
|
||||
_pending[player] = (src, version, time.time() + PENDING_TTL)
|
||||
safe_execute(server, query)
|
||||
|
||||
# If the console never answers (empty hand / command error), tell the player
|
||||
def _timeout_notice() -> None:
|
||||
with _pending_lock:
|
||||
pending = _pending.get(player)
|
||||
if pending is None:
|
||||
return
|
||||
src2, _ver, expire = pending
|
||||
if time.time() < expire:
|
||||
return
|
||||
_pending.pop(player, None)
|
||||
src2.reply(RText(tr("share_timeout"), RColor.red))
|
||||
|
||||
timer = threading.Timer(PENDING_TTL + 0.1, _timeout_notice)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
|
||||
def handle_info(server: PluginServerInterface, info: Info) -> None:
|
||||
if info.is_player:
|
||||
return
|
||||
match = PLAYER_DATA_RE.match(info.content)
|
||||
if match is None:
|
||||
return
|
||||
name = match.group("name")
|
||||
with _pending_lock:
|
||||
pending = _pending.pop(name, None)
|
||||
if pending is None:
|
||||
return
|
||||
src, version, expire = pending
|
||||
if time.time() >= expire:
|
||||
src.reply(RText(tr("share_timeout"), RColor.red))
|
||||
return
|
||||
item = _extract_item(match.group(0), version)
|
||||
if item is None:
|
||||
src.reply(RText(tr("hand_is_empty"), RColor.red))
|
||||
return
|
||||
_send_share(server, src, name, item, version)
|
||||
|
||||
|
||||
def register(server: PluginServerInterface) -> None:
|
||||
server.register_command(Literal("!!share").runs(lambda src: do_share(server, src)))
|
||||
server.register_help_message("!!share", tr("share_help"))
|
||||
@@ -0,0 +1,142 @@
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.version_util import tr
|
||||
|
||||
DATA_PREFIX_RE = re.compile(r"^.+ has the following entity data: ", re.IGNORECASE)
|
||||
# A payload that looks like an item / entity SNBT, not an error line
|
||||
SNBT_ITEM_RE = re.compile(r'id\s*:\s*"', re.IGNORECASE)
|
||||
# Only allow Minecraft-safe name characters
|
||||
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_]")
|
||||
MAX_NAME_LEN = 32
|
||||
|
||||
# player -> last success timestamp
|
||||
_cooldowns: Dict[str, float] = {}
|
||||
|
||||
|
||||
def sanitize_name(raw: str) -> str:
|
||||
"""Keep only [A-Za-z0-9_], cap length. Empty string means invalid."""
|
||||
return SAFE_NAME_RE.sub("", raw or "").strip()[:MAX_NAME_LEN]
|
||||
|
||||
|
||||
def sanitize_cmd(text: str) -> str:
|
||||
"""Neutralize characters that could split a console/RCON command line."""
|
||||
# Newlines/carriage returns would create a second command on stdin
|
||||
return (text or "").replace("\r", " ").replace("\n", " ")
|
||||
|
||||
|
||||
def safe_execute(server: PluginServerInterface, command: str) -> None:
|
||||
server.execute(sanitize_cmd(command))
|
||||
|
||||
|
||||
def check_permission(src: CommandSource, level: int) -> bool:
|
||||
if src.has_permission(level):
|
||||
return True
|
||||
src.reply(RText(tr("required_permission", level), RColor.red))
|
||||
return False
|
||||
|
||||
|
||||
def require_player(src: CommandSource) -> Optional[str]:
|
||||
if not src.is_player:
|
||||
src.reply(RText(tr("player_only"), RColor.red))
|
||||
return None
|
||||
return src.get_info().player
|
||||
|
||||
|
||||
def check_cooldown(src: CommandSource, player: str, bucket: str, seconds: int) -> bool:
|
||||
"""Return True if the player may proceed. seconds <= 0 disables the limit."""
|
||||
if seconds <= 0:
|
||||
return True
|
||||
key = f"{bucket}:{player}"
|
||||
now = time.time()
|
||||
last = _cooldowns.get(key)
|
||||
if last is not None and now - last < seconds:
|
||||
remain = max(1, int(seconds - (now - last) + 0.999))
|
||||
src.reply(RText(tr("on_cooldown", remain), RColor.red))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mark_cooldown(player: str, bucket: str) -> None:
|
||||
_cooldowns[f"{bucket}:{player}"] = time.time()
|
||||
|
||||
|
||||
def strip_data_prefix(payload: str) -> str:
|
||||
"""Strip the '<name> has the following entity data: ' prefix."""
|
||||
return DATA_PREFIX_RE.sub("", payload, count=1).strip()
|
||||
|
||||
|
||||
def looks_like_item_snbt(snbt: str) -> bool:
|
||||
"""True when the text contains an item id field (not a command error line)."""
|
||||
return bool(snbt) and bool(SNBT_ITEM_RE.search(snbt))
|
||||
|
||||
|
||||
def _read_balanced(text: str, start: int, open_ch: str, close_ch: str) -> Optional[str]:
|
||||
"""Return the balanced substring starting at text[start] == open_ch."""
|
||||
if start >= len(text) or text[start] != open_ch:
|
||||
return None
|
||||
depth = 0
|
||||
for i in range(start, len(text)):
|
||||
c = text[i]
|
||||
if c == open_ch:
|
||||
depth += 1
|
||||
elif c == close_ch:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start : i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def extract_compound(payload: str, key: str) -> Optional[str]:
|
||||
"""
|
||||
Extract a top-level field such as SelectedItem from entity NBT text.
|
||||
Supports compound `{...}` and list `[...]` values.
|
||||
"""
|
||||
m = re.search(rf"(?:^|[{{,\s]){re.escape(key)}\s*:", payload)
|
||||
if m is None:
|
||||
return None
|
||||
i = m.end()
|
||||
while i < len(payload) and payload[i].isspace():
|
||||
i += 1
|
||||
if i >= len(payload):
|
||||
return None
|
||||
if payload[i] == "{":
|
||||
return _read_balanced(payload, i, "{", "}")
|
||||
if payload[i] == "[":
|
||||
return _read_balanced(payload, i, "[", "]")
|
||||
# primitive value until comma or closing brace/bracket
|
||||
j = i
|
||||
while j < len(payload) and payload[j] not in ",}]":
|
||||
j += 1
|
||||
return payload[i:j].strip()
|
||||
|
||||
|
||||
def parse_selected_item(entity_nbt: str) -> Optional[str]:
|
||||
"""Pull SelectedItem (or first HandItems entry) out of a full player NBT dump."""
|
||||
value = extract_compound(entity_nbt, "SelectedItem")
|
||||
if value is not None and looks_like_item_snbt(value):
|
||||
return value
|
||||
|
||||
hand_items = extract_compound(entity_nbt, "HandItems")
|
||||
if hand_items is None:
|
||||
return None
|
||||
# HandItems: [{id:"...",Count:1b},{}] — take the first non-empty item
|
||||
inner = re.search(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", hand_items)
|
||||
if inner is not None:
|
||||
item = inner.group(0)
|
||||
if looks_like_item_snbt(item):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def rcon_query(server: PluginServerInterface, command: str) -> Optional[str]:
|
||||
if not server.is_rcon_running():
|
||||
return None
|
||||
try:
|
||||
return server.rcon_query(command)
|
||||
except Exception as e:
|
||||
server.logger.debug(f"RCON query failed: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import Optional
|
||||
|
||||
from mcdreforged.api.all import *
|
||||
|
||||
from iridium.config import config
|
||||
|
||||
# Fallback strings when ServerInterface is unavailable (unit tests / early import)
|
||||
_FALLBACK = {
|
||||
"click_copy": "click to copy data",
|
||||
"click_suggest": "click for data command",
|
||||
"calc_usage": "Usage: !!c <expression>",
|
||||
"share_showing": "is showing",
|
||||
}
|
||||
|
||||
|
||||
def tr(key: str, *args) -> str:
|
||||
si = ServerInterface.get_instance()
|
||||
if si is None:
|
||||
text = _FALLBACK.get(key, key)
|
||||
if args:
|
||||
try:
|
||||
return text.format(*args)
|
||||
except Exception:
|
||||
return text
|
||||
return text
|
||||
return si.tr(f"iridium.{key}", *args)
|
||||
|
||||
|
||||
def get_mc_version(server: PluginServerInterface) -> Optional[Version]:
|
||||
"""Return the detected Minecraft server version, or None."""
|
||||
if config.force_version:
|
||||
try:
|
||||
return Version(config.force_version)
|
||||
except Exception:
|
||||
server.logger.warning(tr("bad_force_version", config.force_version))
|
||||
info = server.get_server_information()
|
||||
if info is None or info.version is None:
|
||||
return None
|
||||
return info.version
|
||||
|
||||
|
||||
def require_version(server: PluginServerInterface, src: CommandSource) -> Optional[Version]:
|
||||
version = get_mc_version(server)
|
||||
if version is None:
|
||||
src.reply(RText(tr("version_unknown"), RColor.red))
|
||||
return version
|
||||
|
||||
|
||||
def is_at_least(version: Version, target: str) -> bool:
|
||||
return version >= Version(target)
|
||||
Reference in new issue
Block a user