release: Iridium 26.9.1

MCDR plugin: item share, hat, player head, calculator, join MOTD.
All rights reserved. Author: 云镜之端/SKYMirror
This commit is contained in:
SKYMirror committed 2026-09-13 02:32:54 +08:00
commit d5ba9743ed
18 files changed
+1796

No files matched your search

+39
View File
@@ -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"))
+112
View File
@@ -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"))
+56
View File
@@ -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
View File
@@ -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"))
+52
View File
@@ -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))
+242
View File
@@ -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)
+213
View File
@@ -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"))
+142
View File
@@ -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
+50
View File
@@ -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)