From b16690b800a7089877dde8ad2fa791400cb34681 Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 21:44:29 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E4=B8=8E=20CI=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能新增: - 图片上传功能 (chat_image) - RCON 自动配置 (rcon_config) - 玩家事件处理 (player_events) - 聊天同步 (chat_sync) - 多语言支持 (lang/) - 玩家数据 API (api/player_data) 改进: - 服务器处理器可配置 (handler.enabled) - 消息同步重构与优化 - 前缀处理器重构 - 主程序结构优化 - README 文档重写 CI/CD: - 添加 pytest 集成测试 - 代码质量检查结果输出到 Step Summary - build-and-release 触发条件改为 v* tag 推送 --- .claude/settings.local.json | 7 + .github/workflows/build-and-release.yml | 42 ++- .github/workflows/ci.yml | 180 ++++++++++ data/config.json | 7 + easybot_mcdr/__init__.py | 2 + easybot_mcdr/api/__init__.py | 0 easybot_mcdr/api/player_data.py | 197 +++++++++++ easybot_mcdr/behavior_impl.py | 151 ++++++++ easybot_mcdr/bridge_behavior.py | 19 +- easybot_mcdr/client_profile.py | 37 ++ easybot_mcdr/config.py | 104 +++--- easybot_mcdr/impl/__init__.py | 4 +- easybot_mcdr/impl/bridge_behavior_impl.py | 48 ++- easybot_mcdr/impl/chat_image.py | 246 +++++++++++++ easybot_mcdr/impl/chat_sync.py | 44 +++ easybot_mcdr/impl/get_server_info.py | 22 +- easybot_mcdr/impl/message_sync.py | 351 +++++++++++-------- easybot_mcdr/impl/player_events.py | 252 ++++++++++++++ easybot_mcdr/impl/player_list.py | 61 +++- easybot_mcdr/impl/prefix_handler.py | 90 ++--- easybot_mcdr/impl/rpc_handlers.py | 37 +- easybot_mcdr/impl/un_bind_notify.py | 4 +- easybot_mcdr/main.py | 324 +++++------------ easybot_mcdr/message.py | 60 ++-- easybot_mcdr/rcon_config.py | 198 +++++++++++ easybot_mcdr/rpc.py | 9 - easybot_mcdr/websocket/__init__.py | 2 + easybot_mcdr/websocket/ws.py | 406 ++++++++++------------ lang/en_us.json | 20 ++ lang/zh_cn.json | 20 ++ readme.md | 204 +++++++++-- requirements.txt | 2 +- tests/conftest.py | 83 +++++ tests/test_websocket.py | 147 ++++++++ 34 files changed, 2560 insertions(+), 820 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/workflows/ci.yml create mode 100644 easybot_mcdr/__init__.py create mode 100644 easybot_mcdr/api/__init__.py create mode 100644 easybot_mcdr/api/player_data.py create mode 100644 easybot_mcdr/behavior_impl.py create mode 100644 easybot_mcdr/client_profile.py create mode 100644 easybot_mcdr/impl/chat_image.py create mode 100644 easybot_mcdr/impl/chat_sync.py create mode 100644 easybot_mcdr/impl/player_events.py create mode 100644 easybot_mcdr/rcon_config.py create mode 100644 easybot_mcdr/websocket/__init__.py create mode 100644 lang/en_us.json create mode 100644 lang/zh_cn.json create mode 100644 tests/conftest.py create mode 100644 tests/test_websocket.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c31caf6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python test_connection.py)" + ] + } +} diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 9f41294..0de66ad 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -2,10 +2,9 @@ name: Build and Release EasyBot-MCDR on: push: - branches: - - main + tags: + - 'v*' workflow_dispatch: - # 允许手动触发工作流 inputs: version: description: '手动指定版本号(可选)' @@ -40,6 +39,31 @@ jobs: - name: 使用MCDReforged打包插件 run: mcdreforged pack + - name: 代码质量检查 + id: quality_check + run: | + pip install ruff pip-audit -q + echo "## 代码质量检查" >> $GITHUB_STEP_SUMMARY + + # Ruff 检查 + if ruff check . 2>&1 | tee /tmp/ruff-result.txt; then + echo "RUFF_STATUS=✅ 通过" >> $GITHUB_ENV + else + echo "RUFF_STATUS=❌ 发现问题" >> $GITHUB_ENV + fi + + # 依赖安全检查 + if pip-audit -r requirements.txt 2>&1 | tee /tmp/audit-result.txt; then + echo "AUDIT_STATUS=✅ 通过" >> $GITHUB_ENV + else + echo "AUDIT_STATUS=❌ 发现问题" >> $GITHUB_ENV + fi + + echo "| 检查项 | 状态 |" >> $GITHUB_STEP_SUMMARY + echo "|--------|------|" >> $GITHUB_STEP_SUMMARY + echo "| Ruff 代码检查 | ${{ env.RUFF_STATUS }} |" >> $GITHUB_STEP_SUMMARY + echo "| 依赖安全 | ${{ env.AUDIT_STATUS }} |" >> $GITHUB_STEP_SUMMARY + - name: 获取版本号 id: get_version run: | @@ -67,7 +91,7 @@ jobs: ${{ env.MCDR_PATH }} - name: 发布到GitHub Release - if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: # 如果是标签推送,使用标签作为版本号 @@ -75,11 +99,17 @@ jobs: name: EasyBot-MCDR ${{ env.VERSION || 'Development Build' }} body: | ## EasyBot-MCDR ${{ env.VERSION || 'Development Build' }} - + ### 版本信息 - 插件版本: ${{ env.VERSION || 'Development Build' }} - MCDReforged: >= 2.14 - + + ### 代码质量 + | 检查项 | 状态 | + |--------|------| + | Ruff 代码检查 | ${{ env.RUFF_STATUS }} | + | 依赖安全 | ${{ env.AUDIT_STATUS }} | + ### 更新内容 draft: false prerelease: contains('${{ env.VERSION }}', 'beta') || contains('${{ env.VERSION }}', 'alpha') diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc114bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,180 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: 集成测试 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: 安装依赖 + run: | + pip install -r requirements.txt + pip install mcdreforged>=2.14 + pip install pytest pytest-asyncio + + - name: 运行测试 + run: pytest -v + lint: + name: 代码检查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 安装 ruff + run: pip install ruff + + - name: Ruff 代码检查 + run: | + if ruff check . --output-file ruff-report.txt 2>&1 | tee ruff-output.txt; then + echo "## ✅ Ruff 代码检查通过" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ Ruff 代码检查发现问题" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat ruff-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + type-check: + name: 类型检查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: 安装依赖 + run: | + pip install -r requirements.txt + pip install mcdreforged>=2.14 + + - name: 安装 pyright + run: pip install pyright + + - name: Pyright 类型检查 + run: | + if pyright 2>&1 | tee pyright-output.txt; then + echo "## ✅ Pyright 类型检查通过" >> $GITHUB_STEP_SUMMARY + else + echo "## ⚠️ Pyright 类型检查发现问题(非阻断)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -20 pyright-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + continue-on-error: true + + security: + name: 安全扫描 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 安装 pip-audit + run: pip install pip-audit + + - name: 审计依赖安全 + run: | + if pip-audit -r requirements.txt 2>&1 | tee audit-output.txt; then + echo "## ✅ 依赖安全检查通过" >> $GITHUB_STEP_SUMMARY + else + echo "## ❌ 依赖安全检查发现问题" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat audit-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + build: + name: 构建 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: 安装依赖 + run: | + pip install -r requirements.txt + pip install mcdreforged>=2.14 + + - name: 构建插件包 + run: mcdreforged pack + + - name: 验证构建产物 + run: | + MCDR_FILE=$(find . -name "*.mcdr" | head -n 1) + if [ -z "$MCDR_FILE" ]; then + echo "错误:未找到 .mcdr 文件" + exit 1 + fi + echo "构建成功: $MCDR_FILE" + ls -lh "$MCDR_FILE" + + - uses: actions/upload-artifact@v4 + with: + name: easybot-mcdr-build + path: "*.mcdr" + + import-check: + name: 导入检查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: 安装依赖 + run: | + pip install -r requirements.txt + pip install mcdreforged>=2.14 + + - name: 检查模块导入 + run: python -c " + import importlib, sys, pathlib + + errors = [] + for f in pathlib.Path('easybot_mcdr').rglob('*.py'): + mod = f.with_suffix('').as_posix().replace('/', '.') + try: + importlib.import_module(mod) + except Exception as e: + # 跳过 MCDR 运行时错误,只关注导入/语法问题 + if isinstance(e, (SyntaxError, ImportError, ModuleNotFoundError)): + errors.append((mod, str(e))) + + if errors: + for m, e in errors: + print(f'失败: {m} -> {e}', file=sys.stderr) + sys.exit(1) + print('所有模块均可导入(或仅有预期的 MCDR 运行时依赖)') + " diff --git a/data/config.json b/data/config.json index 059a004..0fda824 100644 --- a/data/config.json +++ b/data/config.json @@ -4,6 +4,9 @@ "server_name": "server_name", "debug": false, "kick_delay_seconds": 5, + "handler": { + "enabled": true + }, "message_sync": { "ignore_mcdr_command": true }, @@ -43,5 +46,9 @@ "bot_filter": { "enabled": true, "prefixes": ["Bot_", "BOT_", "bot_"] + }, + "image_upload": { + "enabled": false, + "imgbb_api_key": "" } } \ No newline at end of file diff --git a/easybot_mcdr/__init__.py b/easybot_mcdr/__init__.py new file mode 100644 index 0000000..e38ae6e --- /dev/null +++ b/easybot_mcdr/__init__.py @@ -0,0 +1,2 @@ +# EasyBot MCDR Plugin +# Entrypoint: easybot_mcdr.main diff --git a/easybot_mcdr/api/__init__.py b/easybot_mcdr/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easybot_mcdr/api/player_data.py b/easybot_mcdr/api/player_data.py new file mode 100644 index 0000000..cf4a5a8 --- /dev/null +++ b/easybot_mcdr/api/player_data.py @@ -0,0 +1,197 @@ +import queue +import re +import threading +import logging +from typing import Optional + +logger = logging.getLogger("EasyBot") + +NBT_DATA_TYPE_MAP = { + 0: "playerdata", # PlayerData + 1: "advancements", # Advancements + 2: "statistics", # Statistics +} + +DATA_GET_OUTPUT_REGEX = re.compile( + r"^(\w+) has the following entity data: (.+)$" +) + + +DATA_COMMAND_MIN_VERSION = (1, 13) + + +def _parse_version(version_str: str) -> tuple: + match = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", version_str) + if match: + major = int(match.group(1)) + minor = int(match.group(2)) + patch = int(match.group(3)) if match.group(3) else 0 + return (major, minor, patch) + return (0, 0, 0) + + +class PlayerDataGetter: + def __init__(self, server): + self._server = server + self._work_queue: dict[str, queue.Queue] = {} + self._lock = threading.Lock() + self._version_supported = self._check_version() + + def _check_version(self) -> bool: + try: + info = self._server.get_server_information() + version = _parse_version(info.version) + supported = version >= DATA_COMMAND_MIN_VERSION + if not supported: + logger.warning( + f"服务器版本 {info.version} 低于 1.13,NBT 数据读取功能不可用" + ) + return supported + except Exception: + logger.warning("无法获取服务器版本,NBT 数据读取功能可能不可用") + return True + + def on_info(self, text: str): + match = DATA_GET_OUTPUT_REGEX.match(text) + if match: + player_name = match.group(1) + data = match.group(2) + with self._lock: + q = self._work_queue.get(player_name) + if q is not None: + q.put(data) + + def get_player_info(self, player: str, path: str = "", timeout: float = 5.0) -> Optional[str]: + if not self._version_supported: + return None + cmd = f"data get entity {player}" + if path: + cmd += f" {path}" + + q = queue.Queue() + with self._lock: + self._work_queue[player] = q + + try: + self._server.execute(cmd) + result = q.get(timeout=timeout) + return result + except queue.Empty: + return None + finally: + with self._lock: + self._work_queue.pop(player, None) + + def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: + if not self._version_supported: + return None + type_name = NBT_DATA_TYPE_MAP.get(data_type) + if type_name is None: + return None + + if type_name == "playerdata": + return None + + if type_name == "advancements": + try: + result = self._server.rcon_query( + f"data get storage minecraft:player_data {player_uuid}.advancements" + ) + if result: + return {"raw": result} + except Exception: + pass + return None + + if type_name == "statistics": + try: + result = self._server.rcon_query( + f"data get storage minecraft:player_data {player_uuid}.stats" + ) + if result: + return {"raw": result} + except Exception: + pass + return None + + return None + + def get_entity_data(self, player: str, path: str = "", timeout: float = 5.0) -> Optional[dict]: + raw = self.get_player_info(player, path, timeout) + if raw is None: + return None + try: + return {"raw": raw, "parsed": parse_minecraft_json(raw)} + except Exception: + return {"raw": raw} + + +def remove_command_result_prefix(text: str) -> str: + return re.sub(r"^[^ ]* has the following entity data: ", "", text) + + +def preprocess_minecraft_json(text: str) -> str: + result = [] + in_string = False + escape_next = False + i = 0 + + while i < len(text): + ch = text[i] + + if escape_next: + result.append(ch) + escape_next = False + i += 1 + continue + + if ch == '\\' and in_string: + result.append(ch) + escape_next = True + i += 1 + continue + + if ch == '"' and not in_string: + in_string = True + result.append(ch) + i += 1 + continue + + if ch == '"' and in_string: + in_string = False + result.append(ch) + i += 1 + continue + + if in_string: + result.append(ch) + i += 1 + continue + + if ch == '<' and i + 2 < len(text) and text[i+1] == '.' and text[i+2] == '.' and i + 3 < len(text) and text[i+3] == '>': + i += 4 + continue + + result.append(ch) + i += 1 + + text = ''.join(result) + text = re.sub(r'([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)([bsLdf])', r'\1', text) + text = re.sub(r'(?<=\[)[IL];', '', text) + return text + + +def parse_minecraft_json(text: str) -> Optional[dict]: + text = remove_command_result_prefix(text) + text = preprocess_minecraft_json(text) + try: + import hjson + return hjson.loads(text) + except ImportError: + import json + try: + return json.loads(text) + except Exception: + return None + except Exception: + return None diff --git a/easybot_mcdr/behavior_impl.py b/easybot_mcdr/behavior_impl.py new file mode 100644 index 0000000..f19ec73 --- /dev/null +++ b/easybot_mcdr/behavior_impl.py @@ -0,0 +1,151 @@ +import logging +from typing import List, Optional + +from mcdreforged.command.command_source import CommandSource + +logger = logging.getLogger("EasyBot") + +_server = None +_player_data_getter = None + + +def set_server(server): + global _server, _player_data_getter + _server = server + if server is not None: + from .api.player_data import PlayerDataGetter + _player_data_getter = PlayerDataGetter(server) + + +def get_server(): + return _server + + +def get_player_data_getter(): + return _player_data_getter + + +class _OutputCaptureSource(CommandSource): + def __init__(self, server): + super().__init__(server) + self._output = [] + + def reply(self, text): + self._output.append(str(text)) + + def get_output(self) -> str: + return "\n".join(self._output) if self._output else "" + + +def is_mcdr_command(command: str) -> bool: + return command.strip().startswith("!!") + + +def run_command(player_name: str, command: str, enable_papi: bool) -> str: + if _server is None: + return "" + + if is_mcdr_command(command): + return _run_mcdr_command(command) + + try: + result = _server.rcon_query(command) + if result is not None: + return result + except Exception: + pass + + _server.execute(command) + return "" + + +def _run_mcdr_command(command: str) -> str: + try: + source = _OutputCaptureSource(_server) + _server.execute_command(command, source) + output = source.get_output() + return output if output else f"[MCDR] 命令已执行: {command}" + except Exception as e: + return f"[MCDR] 命令执行失败: {e}" + + +def papi_query(player_name: str, query: str) -> str: + return query + + +def get_info() -> dict: + if _server is None: + return {} + info = _server.get_server_information() + return { + "server_name": _server.get_server_name(), + "server_version": info.version, + "plugin_version": "1.7.5", + "is_papi_supported": False, + "is_command_supported": True, + "has_geyser": False, + "is_online_mode": info.is_online_mode, + } + + +def sync_to_chat(message: str): + if _server is None: + return + _server.tell(color=None, msg=message) + + +def sync_to_chat_extra(segments: List, text: str): + parts = [] + for seg in segments: + t = seg.get_text() if hasattr(seg, "get_text") else str(seg) + if t: + parts.append(t) + combined = "".join(parts) if parts else text + sync_to_chat(combined) + + +def bind_success_broadcast(player_name: str, account_id: str, account_name: str): + sync_to_chat(f"[EasyBot] {player_name} 绑定成功! 账号: {account_name}") + + +def kick_player(player: str, kick_message: str): + if _server is None: + return + _server.execute(f"kick {player} {kick_message}") + + +def module_is_installed(module_name: str) -> bool: + if _server is None: + return False + try: + plugin_list = _server.get_plugin_list() + return any(module_name.lower() in p.lower() for p in plugin_list) + except Exception: + return False + + +def module_is_enabled(module_name: str) -> bool: + return module_is_installed(module_name) + + +def get_player_list() -> List[dict]: + if _server is None: + return [] + players = _server.get_online_player_list() + return [{"player_name": p} for p in players] + + +def get_player_skin(player_name: str) -> Optional[dict]: + return None + + +def read_nbt_data(player_uuid: str, data_type: int) -> Optional[dict]: + if _player_data_getter is None: + return None + return _player_data_getter.read_nbt_data(player_uuid, data_type) + + +def get_entity_data(player: str, path: str = "", timeout: float = 5.0) -> Optional[dict]: + if _player_data_getter is None: + return None + return _player_data_getter.get_entity_data(player, path, timeout) diff --git a/easybot_mcdr/bridge_behavior.py b/easybot_mcdr/bridge_behavior.py index 4b1c2eb..4ba7a8d 100644 --- a/easybot_mcdr/bridge_behavior.py +++ b/easybot_mcdr/bridge_behavior.py @@ -1,13 +1,9 @@ -from typing import List, Protocol +from typing import List, Optional, Protocol from easybot_mcdr.message import Segment class BridgeBehavior(Protocol): - """ - 行为适配层,用于抽象“主程序”需要的能力,便于按需替换/扩展。 - """ - def run_command(self, player_name: str, command: str, enable_papi: bool) -> str: ... @@ -15,7 +11,6 @@ def papi_query(self, player_name: str, query: str) -> str: ... def get_info(self): - """返回服务器信息对象(可自定义结构)。""" ... def sync_to_chat(self, message: str) -> None: @@ -32,3 +27,15 @@ def sync_to_chat_extra(self, segments: List[Segment], text: str) -> None: def get_player_list(self): ... + + def module_is_installed(self, name: str) -> bool: + ... + + def module_is_enabled(self, name: str) -> bool: + ... + + def is_authenticated(self, name: str) -> bool: + ... + + def get_player_skin(self, player_name: str) -> Optional[str]: + ... diff --git a/easybot_mcdr/client_profile.py b/easybot_mcdr/client_profile.py new file mode 100644 index 0000000..a076691 --- /dev/null +++ b/easybot_mcdr/client_profile.py @@ -0,0 +1,37 @@ +class ClientProfile: + """ + 静态能力检测状态,参照 Java bridge 的 ClientProfile。 + 在 GET_SERVER_INFO RPC 处理时更新这些状态。 + """ + is_command_supported: bool = True + is_papi_supported: bool = False + is_online_mode: bool = False + is_debug_mode: bool = False + has_geyser: bool = False + has_floodgate: bool = False + has_skins_restorer: bool = False + sync_message_mode: int = 0 + sync_message_money: int = 0 + + plugin_version: str = "unknown" + server_description: str = "" + + @classmethod + def update(cls, **kwargs): + for k, v in kwargs.items(): + if hasattr(cls, k): + setattr(cls, k, v) + + @classmethod + def to_dict(cls) -> dict: + return { + "is_command_supported": cls.is_command_supported, + "is_papi_supported": cls.is_papi_supported, + "is_online_mode": cls.is_online_mode, + "is_debug_mode": cls.is_debug_mode, + "has_geyser": cls.has_geyser, + "has_floodgate": cls.has_floodgate, + "has_skins_restorer": cls.has_skins_restorer, + "sync_message_mode": cls.sync_message_mode, + "sync_message_money": cls.sync_message_money, + } diff --git a/easybot_mcdr/config.py b/easybot_mcdr/config.py index 9c39d7c..b6f0794 100644 --- a/easybot_mcdr/config.py +++ b/easybot_mcdr/config.py @@ -1,86 +1,96 @@ +import copy import json import os +import threading from mcdreforged.api.all import * -config = {} +_config = {} +_config_lock = threading.Lock() + def load_config(server: PluginServerInterface): - global config + global _config server.logger.info("加载配置中...") config_path = server.get_data_folder() os.makedirs(config_path, exist_ok=True) config_file_path = os.path.join(config_path, "config.json") - - # 用户配置文件路径 + user_config_path = os.path.join("plugins", "easybot-mcdr-main", "config.json") - + try: - # 优先尝试加载用户配置文件 if os.path.exists(user_config_path): server.logger.info(f"检测到用户配置文件: {user_config_path}") with open(user_config_path, "r", encoding="utf-8-sig") as f: - config = json.load(f) - - # 保存用户配置到插件目录 + new_config = json.load(f) with open(config_file_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=4, ensure_ascii=False) + json.dump(new_config, f, indent=4, ensure_ascii=False) server.logger.info(f"用户配置已保存到: {config_file_path}") else: - # 没有用户配置则使用默认配置 if not os.path.exists(config_file_path): with server.open_bundled_file("data/config.json") as data: with open(config_file_path, "w", encoding="utf-8", newline='') as f: f.write(data.read().decode("utf-8-sig")) server.logger.info("配置文件不存在,已创建默认配置文件") - - # 加载配置文件 + with open(config_file_path, "r", encoding="utf-8-sig", newline='') as f: - config = json.load(f) - - # 验证和修复配置 - if "events" in config: - for event_type, event_config in config["events"].items(): + new_config = json.load(f) + + with _config_lock: + _config = new_config + + _validate_config(server) + + except json.JSONDecodeError as e: + server.logger.error(f"配置文件解析失败: {e}") + with server.open_bundled_file("data/config.json") as data: + with _config_lock: + _config = json.loads(data.read().decode("utf-8-sig")) + server.logger.info("已恢复默认配置") + + _ensure_defaults(server) + + +def _validate_config(server: PluginServerInterface): + with _config_lock: + if "events" in _config: + for event_type, event_config in _config["events"].items(): if "comamnds" in event_config and not isinstance(event_config["comamnds"], list): server.logger.warning(f"修复事件 {event_type} 的命令列表格式") event_config["comamnds"] = [] - - # 确保bot_filter存在 - if "bot_filter" not in config: - config["bot_filter"] = { + + +def _ensure_defaults(server: PluginServerInterface): + changed = False + with _config_lock: + if "bot_filter" not in _config: + _config["bot_filter"] = { "enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"] } - save_config(server) - - # 确保踢出延迟配置存在 - if "kick_delay_seconds" not in config: - config["kick_delay_seconds"] = 5 - save_config(server) - - server.logger.info(f"配置文件路径: {config_file_path}") - server.logger.info("配置文件加载成功") - - except json.JSONDecodeError as e: - server.logger.error(f"配置文件解析失败: {e}") - # 创建默认配置 - with server.open_bundled_file("data/config.json") as data: - config = json.loads(data.read().decode("utf-8-sig")) - server.logger.info("已恢复默认配置") - - # 如果缺少 bot_filter,动态添加默认值 - if "bot_filter" not in config: - config["bot_filter"] = { - "enabled": True, - "prefixes": ["Bot_", "BOT_", "bot_"] - } + changed = True + if "kick_delay_seconds" not in _config: + _config["kick_delay_seconds"] = 5 + changed = True + if "handler" not in _config: + _config["handler"] = {"enabled": True} + changed = True + if "image_upload" not in _config: + _config["image_upload"] = {"enabled": False, "imgbb_api_key": ""} + changed = True + if changed: save_config(server) + def save_config(server: PluginServerInterface): config_path = server.get_data_folder() config_file_path = os.path.join(config_path, "config.json") + with _config_lock: + snapshot = copy.deepcopy(_config) with open(config_file_path, "w", encoding="utf-8", newline='') as f: - json.dump(config, f, indent=4, ensure_ascii=False) + json.dump(snapshot, f, indent=4, ensure_ascii=False) server.logger.info("配置文件已保存") + def get_config() -> dict: - return config \ No newline at end of file + with _config_lock: + return copy.deepcopy(_config) \ No newline at end of file diff --git a/easybot_mcdr/impl/__init__.py b/easybot_mcdr/impl/__init__.py index 4bfff56..21f42c8 100644 --- a/easybot_mcdr/impl/__init__.py +++ b/easybot_mcdr/impl/__init__.py @@ -6,4 +6,6 @@ from . import message_sync from . import exec_command from . import cross_server_chat -from . import sync_settings \ No newline at end of file +from . import sync_settings +from . import player_events +from . import chat_sync \ No newline at end of file diff --git a/easybot_mcdr/impl/bridge_behavior_impl.py b/easybot_mcdr/impl/bridge_behavior_impl.py index e04988a..7450b2f 100644 --- a/easybot_mcdr/impl/bridge_behavior_impl.py +++ b/easybot_mcdr/impl/bridge_behavior_impl.py @@ -1,8 +1,8 @@ -from typing import List +from typing import List, Optional from mcdreforged.api.all import PluginServerInterface from easybot_mcdr.bridge_behavior import BridgeBehavior -from easybot_mcdr.message import Segment, segments_to_list +from easybot_mcdr.message import Segment class DefaultBridgeBehavior(BridgeBehavior): @@ -10,21 +10,17 @@ def __init__(self, server: PluginServerInterface): self.server = server def run_command(self, player_name: str, command: str, enable_papi: bool) -> str: - # PAPI 支持已移除,直接执行原命令 cmd = command - try: if self.server.is_rcon_running(): return str(self.server.rcon_query(cmd)) else: - # fallback:直接执行命令 self.server.execute(cmd) return "executed" except Exception as e: return f"error: {e}" def papi_query(self, player_name: str, query: str) -> str: - # 已移除 PAPI 支持,原样返回 return query def get_info(self): @@ -47,16 +43,14 @@ def kick_player(self, player: str, kick_message: str): try: self.server.execute(f"kick {player} {kick_message}") except Exception: - # fallback to RCON if available if self.server.is_rcon_running(): self.server.rcon_query(f"kick {player} {kick_message}") def sync_to_chat_extra(self, segments: List[Segment], text: str): - # 简化:仅用文本部分输出,其余 segment 转换为字符串描述 try: if segments: - pretty = " ".join([s.to_dict().__str__() for s in segments]) - self.server.say(f"{text} {pretty}") + from easybot_mcdr.impl.message_sync import render_segments + render_segments(segments, text) else: self.server.say(text) except Exception: @@ -64,3 +58,37 @@ def sync_to_chat_extra(self, segments: List[Segment], text: str): def get_player_list(self): return list(self.server.get_online_players()) + + def module_is_installed(self, name: str) -> bool: + return False + + def module_is_enabled(self, name: str) -> bool: + return False + + def is_authenticated(self, name: str) -> bool: + return False + + def get_player_skin(self, player_name: str) -> Optional[str]: + from easybot_mcdr.impl.get_server_info import get_online_mode, get_skins_restorer + online = get_online_mode() + has_sr = get_skins_restorer() + + if online or has_sr: + return f"https://mineskin.eu/download/{player_name}" + + # 离线模式无皮肤站: 查询 Mojang 判断是否正版 + from easybot_mcdr.impl.player_list import _check_premium_sync + if _check_premium_sync(player_name): + return f"https://mineskin.eu/download/{player_name}" + + # 非正版: 尝试通过 UUID 获取头像 + try: + players = self.server.get_online_players() + for p in players: + if hasattr(p, 'name') and p.name == player_name: + return f"https://mc-heads.net/skin/{p.uuid}" + if str(p) == player_name: + return f"https://mc-heads.net/skin/{p.uuid}" + except Exception: + pass + return None diff --git a/easybot_mcdr/impl/chat_image.py b/easybot_mcdr/impl/chat_image.py new file mode 100644 index 0000000..d998427 --- /dev/null +++ b/easybot_mcdr/impl/chat_image.py @@ -0,0 +1,246 @@ +import json +import os +import re +import socket +import threading +import uuid +import http.server +import http.client +import mimetypes +from typing import List, Tuple, Optional + + +# ---------- Local HTTP Server ---------- + +_local_server = None +_local_server_lock = threading.Lock() + + +class _ImageFileHandler(http.server.BaseHTTPRequestHandler): + """轻量 HTTP handler,只提供文件读取""" + + def do_GET(self): + # URL 格式: // + parts = self.path.strip("/").split("/", 1) + if len(parts) < 2: + self.send_error(404) + return + file_path = parts[1] + # 还原路径分隔符 + file_path = file_path.replace("__SLASH__", "/").replace("__BACK__", "\\") + if not os.path.isfile(file_path): + self.send_error(404) + return + try: + content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + with open(file_path, "rb") as f: + data = f.read() + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(data) + except Exception: + self.send_error(500) + + def log_message(self, format, *args): + pass # 静默日志 + + +def _get_local_ip() -> str: + """获取本机局域网 IP""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +def start_local_image_server(port: int = 0): + """启动本地图片服务器""" + global _local_server + with _local_server_lock: + if _local_server is not None: + return + server = http.server.HTTPServer(("0.0.0.0", port), _ImageFileHandler) + _local_server = server + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + try: + from mcdreforged.api.all import ServerInterface + ServerInterface.get_instance().logger.info( + f"[EasyBot-IMG] 本地图片服务器已启动: http://{_get_local_ip()}:{server.server_address[1]}") + except Exception: + pass + + +def stop_local_image_server(): + global _local_server + with _local_server_lock: + if _local_server: + _local_server.shutdown() + _local_server = None + + +def _local_file_url(file_path: str) -> Optional[str]: + """将本地文件路径转为本地 HTTP URL""" + with _local_server_lock: + if _local_server is None: + return None + port = _local_server.server_address[1] + ip = _get_local_ip() + # 编码路径: / → __SLASH__, \ → __BACK__ + encoded = file_path.replace("/", "__SLASH__").replace("\\", "__BACK__") + token = uuid.uuid4().hex[:8] + return f"http://{ip}:{port}/{token}/{encoded}" + + +# ---------- File URL Replacement ---------- + +def _upload_to_imgbb(file_path: str, api_key: str) -> Optional[str]: + """上传到 imgbb 图床,返回网络 URL""" + import base64 + import urllib.request + import urllib.parse + try: + with open(file_path, "rb") as f: + img_data = base64.b64encode(f.read()).decode("utf-8") + data = urllib.parse.urlencode({"key": api_key, "image": img_data}).encode("utf-8") + req = urllib.request.Request("https://api.imgbb.com/1/upload", data=data) + with urllib.request.urlopen(req, timeout=30) as resp: + result = json.loads(resp.read().decode("utf-8")) + if result.get("success"): + return result["data"]["url"] + except Exception: + pass + return None + + +def convert_file_url(url: str) -> str: + """将单个 file:// URL 转为可访问的网络 URL。非 file:// URL 原样返回。""" + if not url.startswith("file://"): + return url + from mcdreforged.api.all import ServerInterface + from easybot_mcdr.config import get_config + logger = ServerInterface.get_instance().logger + imgbb_key = get_config().get("image_upload", {}).get("imgbb_api_key", "") + file_path = url[len("file://"):] + if os.name == "nt" and len(file_path) > 2 and file_path[0] == "/" and file_path[2] == ":": + file_path = file_path[1:] + file_path = file_path.replace("\\", "/") if os.name == "nt" else file_path + if not os.path.isfile(file_path): + logger.info(f"[EasyBot-IMG] file not found: {file_path}") + return url + if imgbb_key: + web_url = _upload_to_imgbb(file_path, imgbb_key) + if web_url: + logger.info(f"[EasyBot-IMG] imgbb url={web_url}") + return web_url + web_url = _local_file_url(file_path) + if web_url: + logger.info(f"[EasyBot-IMG] local url={web_url}") + return web_url + logger.info(f"[EasyBot-IMG] no upload method available") + return url + + +def replace_file_urls(text: str) -> str: + """将 CICode 中的 file:// URL 替换为可访问的 HTTP URL。 + 有 imgbb key 时优先上传 imgbb,否则用本地服务器。 + """ + from mcdreforged.api.all import ServerInterface + from easybot_mcdr.config import get_config + logger = ServerInterface.get_instance().logger + imgbb_key = get_config().get("image_upload", {}).get("imgbb_api_key", "") + + def _replace(m): + url = m.group("url") or "" + if not url.startswith("file://"): + return m.group(0) + file_path = url[len("file://"):] + if os.name == "nt" and len(file_path) > 2 and file_path[0] == "/" and file_path[2] == ":": + file_path = file_path[1:] + file_path = file_path.replace("\\", "/") if os.name == "nt" else file_path + if not os.path.isfile(file_path): + logger.info(f"[EasyBot-IMG] file not found: {file_path}") + return m.group(0) + # 优先 imgbb + if imgbb_key: + web_url = _upload_to_imgbb(file_path, imgbb_key) + if web_url: + logger.info(f"[EasyBot-IMG] imgbb url={web_url}") + return m.group(0).replace(url, web_url) + # 回退到本地服务器 + web_url = _local_file_url(file_path) + if web_url: + logger.info(f"[EasyBot-IMG] local url={web_url}") + return m.group(0).replace(url, web_url) + logger.info(f"[EasyBot-IMG] no upload method available") + return m.group(0) + + return _CICODE_PATTERN.sub(_replace, text) + + +# ---------- CICode Parsing ---------- + +_CICODE_PATTERN = re.compile( + r'\[\[CICode,(?:.*?url=(?P[^,\]]+).*?)?\]\]' +) + +_CQCODE_PATTERN = re.compile( + r'\[CQ:image,(?:.*?file=(?P[^,\]]+).*?)?\]' +) + +_IMAGE_URL_PATTERN = re.compile( + r'https?://\S+\.(?:png|jpe?g|gif|bmp|ico|webp)(?:\?\S*)?', + re.IGNORECASE +) + + +def parse_chat_image(text: str) -> List[Tuple[str, str]]: + """从聊天文本中提取图片。返回 [(url, name), ...] 列表。""" + results = [] + for m in _CICODE_PATTERN.finditer(text): + url = m.group("url") or "" + if url and not url.startswith("file://"): + name_match = re.search(r'name=([^,\]]+)', m.group(0)) + name = name_match.group(1) if name_match else "图片" + results.append((url, name)) + for m in _CQCODE_PATTERN.finditer(text): + url = m.group("url") or "" + if url and not url.startswith("file://"): + results.append((url, "图片")) + if not results: + for m in _IMAGE_URL_PATTERN.finditer(text): + url = m.group() + if not url.startswith("file://"): + results.append((url, "图片")) + return results + + +def has_chat_image(text: str) -> bool: + """检查文本中是否包含 ChatImage 图片""" + if _CICODE_PATTERN.search(text): + return True + if _CQCODE_PATTERN.search(text): + return True + if not _CICODE_PATTERN.search(text) and not _CQCODE_PATTERN.search(text): + if _IMAGE_URL_PATTERN.search(text): + return True + return False + + +def to_cicode(url: str, name: str = "图片") -> str: + """将图片 URL 转为 CICode 格式,供 ChatImage 客户端渲染""" + return f"[[CICode,url={url},name={name},nsfw=false]]" + + +def strip_image_codes(text: str) -> str: + """移除文本中的 CICode/CQCode,保留纯文本部分""" + result = _CICODE_PATTERN.sub("", text) + result = _CQCODE_PATTERN.sub("", result) + return result.strip() diff --git a/easybot_mcdr/impl/chat_sync.py b/easybot_mcdr/impl/chat_sync.py new file mode 100644 index 0000000..27350b7 --- /dev/null +++ b/easybot_mcdr/impl/chat_sync.py @@ -0,0 +1,44 @@ +from mcdreforged.api.all import * +from easybot_mcdr.config import get_config + + +async def on_user_info(server: PluginServerInterface, info: Info): + if info.player is None: + return + if ( + info.content.startswith("!!") + and get_config()["message_sync"]["ignore_mcdr_command"] + ): + return + from easybot_mcdr.main import wsc + + # 检测 ChatImage CICode/CQCode,替换 file:// 为网络 URL + from easybot_mcdr.impl.chat_image import parse_chat_image, strip_image_codes, replace_file_urls + from easybot_mcdr.config import get_config as _cfg + cfg = _cfg().get("image_upload", {}) + if cfg.get("enabled"): + info.content = replace_file_urls(info.content) + images = parse_chat_image(info.content) + + if images: + # 有图片: 发送文本部分 + IMAGE segments + text_part = strip_image_codes(info.content) + extra = [] + if text_part: + extra.append({"type": 2, "text": text_part}) + for url, name in images: + extra.append({"type": 3, "url": url, "summary": name}) + await wsc.push_message(info.player, text_part or "", False, extra=extra) + else: + await wsc.push_message(info.player, info.content, False) + + +async def cross_server_say(source: CommandSource, context: CommandContext): + if not source.is_player: + source.reply("§c这个命令只能由玩家使用!") + return + player = source.player + message = context["message"] + from easybot_mcdr.main import wsc + await wsc.push_cross_server_message(player, message) + source.reply("§a你的消息已发送到其他服务器.") diff --git a/easybot_mcdr/impl/get_server_info.py b/easybot_mcdr/impl/get_server_info.py index aa5dc40..bbd3495 100644 --- a/easybot_mcdr/impl/get_server_info.py +++ b/easybot_mcdr/impl/get_server_info.py @@ -1,5 +1,6 @@ import os import re +import glob from easybot_mcdr.meta import get_plugin_version from easybot_mcdr.websocket.context import ExecContext from easybot_mcdr.websocket.ws import EasyBotWsClient @@ -7,10 +8,11 @@ # 初始化在线模式变量,默认为False(离线模式) is_online_mode = False +has_skins_restorer = False @EasyBotWsClient.listen_exec_op("GET_SERVER_INFO") async def exec_get_server_info(ctx: ExecContext, data:dict, _): - global is_online_mode + global is_online_mode, has_skins_restorer server = ServerInterface.get_instance() working_directory = server.get_mcdr_config()["working_directory"] properties_path = os.path.join(working_directory, "server.properties") @@ -18,6 +20,12 @@ async def exec_get_server_info(ctx: ExecContext, data:dict, _): with open(properties_path, "r", encoding='utf-8') as f: online_mode = re.search(r"online-mode=(.*)", f.read()).group(1) online_mode = str(online_mode).lower().strip() == "true" + + # 检测 SkinsRestorer 插件 + plugins_dir = os.path.join(working_directory, "plugins") + sr_found = bool(glob.glob(os.path.join(plugins_dir, "SkinsRestorer*.jar"))) + has_skins_restorer = sr_found + try: packet = { "server_name": "mcdr", @@ -26,17 +34,18 @@ async def exec_get_server_info(ctx: ExecContext, data:dict, _): "is_papi_supported": False, "is_command_supported": True, "has_geyser": False, + "has_skins_restorer": sr_found, "is_online_mode": online_mode } # 确保所有字符串都是UTF-8编码 - packet = {k: v.encode('utf-8').decode('utf-8') if isinstance(v, str) else v + packet = {k: v.encode('utf-8').decode('utf-8') if isinstance(v, str) else v for k, v in packet.items()} except Exception as e: server.logger.error(f"构建服务器信息包时出错: {str(e)}") raise is_online_mode = online_mode await ctx.callback(packet) - ServerInterface.get_instance().logger.info(f"{packet['server_version']} 正版验证: {'是' if online_mode else '否'}") + ServerInterface.get_instance().logger.info(f"{packet['server_version']} 正版验证: {'是' if online_mode else '否'} SkinsRestorer: {'是' if sr_found else '否'}") return def get_online_mode(): @@ -49,7 +58,7 @@ def get_online_mode(): server = ServerInterface.get_instance() working_directory = server.get_mcdr_config()["working_directory"] properties_path = os.path.join(working_directory, "server.properties") - + # 直接读取并解析文件,与exec_get_server_info中的逻辑类似 with open(properties_path, "r", encoding='utf-8') as f: content = f.read() @@ -69,6 +78,11 @@ def get_online_mode(): # 出错时返回当前全局变量的值 return is_online_mode + +def get_skins_restorer() -> bool: + """返回 SkinsRestorer 插件是否已检测到""" + return has_skins_restorer + @new_thread("EasyBot-GetPlayers") def get_online_players(server: PluginServerInterface): try: diff --git a/easybot_mcdr/impl/message_sync.py b/easybot_mcdr/impl/message_sync.py index a9c8cb7..589ebdf 100644 --- a/easybot_mcdr/impl/message_sync.py +++ b/easybot_mcdr/impl/message_sync.py @@ -1,77 +1,108 @@ import time +from typing import Any, Dict, List from easybot_mcdr.config import get_config +from easybot_mcdr.impl.chat_image import parse_chat_image, strip_image_codes +from easybot_mcdr.message import Segment, SegmentType, segments_from_list from easybot_mcdr.websocket.context import ExecContext from easybot_mcdr.websocket.ws import EasyBotWsClient from mcdreforged.api.all import * -@EasyBotWsClient.listen_exec_op("SEND_TO_CHAT") -async def sync_message(ctx: ExecContext, data:dict, _): - # 输入验证 - if not isinstance(data, dict): - ServerInterface.get_instance().logger.error("无效的消息数据格式") - return - - # 安全获取text - text = str(data.get("text", "")) - - # 处理extra数据 - extra_data = [] - if isinstance(data.get("extra"), list): - extra_data = data["extra"] - elif data.get("extra") is not None: - ServerInterface.get_instance().logger.warning(f"无效的extra格式: {type(data['extra'])}") - if not extra_data: - ServerInterface.get_instance().broadcast(text) - ServerInterface.get_instance().logger.info(text) - return - +def _flatten_extra(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Flatten nested extra arrays, extracting [[...]] image markers as IMAGE segments.""" + import re + _DOUBLE_BRACKET = re.compile(r'\[\[(.+?)\]\]') + + def _is_img_marker(item: dict) -> bool: + text = str(item.get("text", "")) + ce = item.get("clickEvent") + return ( + _DOUBLE_BRACKET.search(text) + and isinstance(ce, dict) + and ce.get("action") == "open_url" + ) + + result = [] + for item in data: + if not isinstance(item, dict): + result.append(item) + continue + + nested = item.get("extra") + if isinstance(nested, list) and nested: + has_img = any(_is_img_marker(x) for x in nested if isinstance(x, dict)) + if has_img: + for x in nested: + if not isinstance(x, dict): + if isinstance(x, str) and x: + result.append({"type": SegmentType.TEXT, "text": x}) + continue + if _is_img_marker(x): + url = x.get("clickEvent", {}).get("value", "") + m = _DOUBLE_BRACKET.search(str(x.get("text", ""))) + summary = m.group(1) if m else "图片" + result.append({"type": SegmentType.IMAGE, "url": url, "summary": summary}) + else: + t = str(x.get("text", "")) + if t: + result.append({"type": SegmentType.TEXT, "text": t}) + else: + result.extend(_flatten_extra(nested)) + continue + + text = item.get("text", "") + if isinstance(text, str) and parse_chat_image(text): + for url, name in parse_chat_image(text): + result.append({"type": SegmentType.IMAGE, "url": url, "summary": name}) + clean = strip_image_codes(text) + if clean: + result.append({"type": SegmentType.TEXT, "text": clean}) + else: + result.append(item) + return result + + +def render_segments(segments: List[Segment], text: str = ""): + server = ServerInterface.get_instance() text_list = RTextList() at_players = [] current_text = "" has_at_all = False + has_cicode = False - def append_current_text(): + def flush_text(): nonlocal current_text if current_text: text_list.append(RText(current_text)) current_text = "" - # 确保extra_data是可迭代的 - extra_data = extra_data if isinstance(extra_data, list) else [] - for segment in extra_data: - if not isinstance(segment, dict): - continue - - seg_type = segment.get("type", 0) - - # 处理text类型 - if seg_type == 2: - text = str(segment.get("text", "")) - current_text += text - + for seg in segments: + t = seg.type + + if t == SegmentType.TEXT: + current_text += seg.text + else: - append_current_text() # 遇到非text类型时先提交暂存文本 - - # 处理image类型 - if seg_type == 3: - url = str(segment.get("url", "")) - image_text = RText("[图片]") - image_text.set_hover_text("点击预览") + flush_text() + + if t == SegmentType.IMAGE: + url = getattr(seg, "url", "") + summary = getattr(seg, "summary", None) or "图片" if url: - image_text.set_click_event(RAction.open_url, url) - image_text.set_color(RColor.green) - text_list.append(image_text) - - # 处理at类型 - elif seg_type == 4: - at_names = segment.get("at_player_names", []) or [] - if not isinstance(at_names, list): - at_names = [] - - user_id = str(segment.get('at_user_id', "")) - user_name = str(segment.get('at_user_name', "")) - + from easybot_mcdr.impl.chat_image import to_cicode + # 用纯字符串输出 CICode,确保 ChatImage 能解析 + server.broadcast(to_cicode(url, summary)) + has_cicode = True + else: + el = RText(f"[{summary}]") + el.set_color(RColor.green) + text_list.append(el) + + elif t == SegmentType.AT: + at_names = getattr(seg, "at_player_names", []) or [] + user_id = str(getattr(seg, "at_user_id", "")) + user_name = str(getattr(seg, "at_user_name", "")) + if user_id == "0": at_text = RText("@全体成员") has_at_all = True @@ -79,93 +110,135 @@ def append_current_text(): at_text = RText(user_name) else: at_text = RText("@" + ",".join(at_names)) - + at_text.set_color(RColor.gold) at_text.set_hover_text(f"社交账号: {user_name}({user_id})") - for player in at_names: - if isinstance(player, str): - at_players.append(player) + for p in at_names: + if isinstance(p, str): + at_players.append(p) text_list.append(at_text) - - # 处理file类型 - elif seg_type == 5: - file_text = RText("[文件]") - file_text.set_color(RColor.green) - text_list.append(file_text) - - # 处理reply类型 - elif seg_type == 6: - reply_text = RText("[回复某条消息]") - reply_text.set_color(RColor.gray) - text_list.append(reply_text) - - # 处理 face 类型 - elif seg_type == 7: - # Java版 FaceSegment: displayName - face_name = str(segment.get("display_name", "表情")) - face_text = RText(f"[{face_name}]") - face_text.set_color(RColor.yellow) - text_list.append(face_text) - append_current_text() - ServerInterface.get_instance().broadcast(text_list) - - config = get_config()["events"]["message"]["on_at"] + + elif t == SegmentType.FILE: + el = RText("[文件]") + el.set_color(RColor.green) + text_list.append(el) + + elif t == SegmentType.REPLY: + el = RText("[回复某条消息]") + el.set_color(RColor.gray) + text_list.append(el) + + elif t == SegmentType.FACE: + face_name = getattr(seg, "display_name", None) or "表情" + el = RText(f"[{face_name}]") + el.set_color(RColor.yellow) + text_list.append(el) + + flush_text() + # 有 CICode 时,剩余部分也用纯字符串广播,保持一致性 + if has_cicode: + remaining = str(text_list) + if remaining.strip(): + server.broadcast(remaining) + else: + server.broadcast(text_list) + return at_players, has_at_all + + +def _execute_at_commands(at_players, has_at_all): + config = get_config().get("events", {}).get("message", {}).get("on_at", {}) logger = ServerInterface.get_instance().logger - # @判断 - if config["exec_command"] and "comamnds" in config: - commands = config["comamnds"] - if not isinstance(commands, list): - logger.warning("命令列表格式无效,已跳过执行") - return - - if has_at_all: - for command in commands: - if not isinstance(command, str): - continue - try: - cmd = command.replace("#player", "@a") - ServerInterface.get_instance().execute(cmd) - except Exception as e: - logger.error(f"执行命令失败: {cmd} ({str(e)})") - else: - for player in at_players: - from easybot_mcdr.api.player import check_online - if check_online(player): - for command in commands: - if not isinstance(command, str): - continue - try: - cmd = command.replace("#player", player) - ServerInterface.get_instance().execute(cmd) - except Exception as e: - logger.error(f"执行命令失败: {cmd} ({str(e)})") - - def play_sound(count, interval, sound_command, player): - for i in range(count): - logger.info(command.replace("#player", player)) - ServerInterface.get_instance().execute(sound_command.replace("#player", player)) + + if not config.get("exec_command"): + return + commands = config.get("comamnds", []) + if not isinstance(commands, list): + logger.warning("命令列表格式无效,已跳过执行") + return + + targets = ["@a"] if has_at_all else [ + p for p in at_players + if _check_online(p) + ] + + for player in targets: + for command in commands: + if not isinstance(command, str): + continue + try: + cmd = command.replace("#player", player) + ServerInterface.get_instance().execute(cmd) + except Exception as e: + logger.error(f"执行命令失败: {cmd} ({str(e)})") + + +def _execute_at_sound(at_players, has_at_all): + config = get_config().get("events", {}).get("message", {}).get("on_at", {}) + logger = ServerInterface.get_instance().logger + sound_cfg = config.get("sound", {}) + + if not sound_cfg.get("play_sound"): + return + command = sound_cfg.get("run") + if not isinstance(command, str): + logger.warning("音效命令配置无效,已跳过") + return + + count = sound_cfg.get("count", 1) + interval = sound_cfg.get("interval_ms", 1000) + + targets = ["@a"] if has_at_all else [ + p for p in at_players + if _check_online(p) + ] + + for player in targets: + for _ in range(count): + try: + ServerInterface.get_instance().execute(command.replace("#player", player)) + except Exception as e: + logger.error(f"执行音效命令失败: {e}") time.sleep(interval / 1000) - if "sound" in config and config["sound"]["play_sound"]: - if "run" not in config["sound"] or not isinstance(config["sound"]["run"], str): - logger.warning("音效命令配置无效,已跳过") - return - - command = config["sound"]["run"] - if has_at_all: - play_sound( - config["sound"].get("count", 1), - config["sound"].get("interval_ms", 1000), - command, - "@a" - ) - else: - for player in at_players: - from easybot_mcdr.api.player import check_online - if check_online(player): - play_sound( - config["sound"].get("count", 1), - config["sound"].get("interval_ms", 1000), - command, - player - ) \ No newline at end of file + +def _check_online(player: str) -> bool: + from easybot_mcdr.api.player import check_online + return check_online(player) + + +@EasyBotWsClient.listen_exec_op("SEND_TO_CHAT") +async def sync_message(ctx: ExecContext, data: dict, _): + if not isinstance(data, dict): + ServerInterface.get_instance().logger.error("无效的消息数据格式") + return + + text = str(data.get("text", "")) + + extra_data = data.get("extra") + if not isinstance(extra_data, list): + extra_data = [] + + # Replace file:// URLs with network-accessible URLs (imgbb or local server) + from easybot_mcdr.config import get_config as _cfg + img_cfg = _cfg().get("image_upload", {}) + if img_cfg.get("enabled"): + from easybot_mcdr.impl.chat_image import replace_file_urls, convert_file_url + text = replace_file_urls(text) + for item in extra_data: + if isinstance(item, dict) and "url" in item: + item["url"] = convert_file_url(str(item["url"])) + if isinstance(item, dict) and "text" in item: + item["text"] = replace_file_urls(str(item["text"])) + + if not extra_data: + ServerInterface.get_instance().broadcast(text) + ServerInterface.get_instance().logger.info(text) + return + + # Flatten nested extra arrays to extract [[...]] image markers + flat_data = _flatten_extra(extra_data) + segments = segments_from_list(flat_data) + at_players, has_at_all = render_segments(segments, text) + + _execute_at_commands(at_players, has_at_all) + _execute_at_sound(at_players, has_at_all) diff --git a/easybot_mcdr/impl/player_events.py b/easybot_mcdr/impl/player_events.py new file mode 100644 index 0000000..abd5a2f --- /dev/null +++ b/easybot_mcdr/impl/player_events.py @@ -0,0 +1,252 @@ +import asyncio +import re +import time +from mcdreforged.api.all import * + + +async def on_player_joined(server: PluginServerInterface, player: str, info: Info): + from easybot_mcdr.main import wsc, is_bot_player, kick_map + from easybot_mcdr.api.player import cached_data + from easybot_mcdr.config import get_config + + try: + config = get_config() + bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) + server.logger.debug(f"假人过滤配置: enabled={bot_filter['enabled']}, prefixes={bot_filter['prefixes']}") + + if is_bot_player(player): + ip = "unknown" + if match := re.search(r'\d+\.\d+\.\d+\.\d+', info.raw_content): + ip = match.group() + player_info = cached_data.get(player) + uuid = player_info.uuid if player_info else "unknown" + server.logger.info(f"检测到假人 {player} (匹配前缀: {bot_filter['prefixes']}), UUID={uuid}, IP={ip}") + return + + player_info = await wsc.report_player(player) + if player_info is None: + server.logger.warning(f"玩家 {player} 的信息未准备好,可能是数据同步延迟") + return + server.logger.info(f"玩家 {player} 已加入并缓存: UUID={player_info['player_uuid']}, IP={player_info['ip']}") + res = await wsc.login(player) + if res["kick"]: + kick_msg = res.get("kick_message", "验证失败") + server.logger.info(f"检测到玩家 {player} 需要被踢出,等待加载延迟...") + kick_map[player] = time.time() + if player not in kick_map: + kick_map[player] = time.time() + + server.tell(player, f"§c[EasyBot] 验证未通过: {kick_msg}") + server.tell(player, "§c[EasyBot] 您将在 5 秒后被移出服务器,请按照提示操作。") + + kick_delay = get_config().get("kick_delay_seconds", 5) + await asyncio.sleep(kick_delay) + _push_kick(player, kick_msg) + return + + if player in kick_map: + kick_map.pop(player) + + await wsc.push_enter(player) + + # 检查 RCON 配置并提示管理员 + notify_rcon_not_configured(server, player) + + except Exception as e: + server.logger.error(f"处理玩家 {player} 加入时出错: {e}") + import traceback + server.logger.debug(f"{traceback.format_exc()}") + + +async def on_player_left(server: PluginServerInterface, player: str): + from easybot_mcdr.main import wsc, is_bot_player, kick_map, exit_reported_at, debounce_time + from easybot_mcdr.config import get_config + + config = get_config() + bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) + server.logger.debug(f"处理玩家退出事件: {player}, 假人过滤状态: enabled={bot_filter['enabled']}") + + if player in kick_map: + if time.time() - kick_map[player] < 15: + server.logger.debug(f"玩家 {player} 是被踢出的,跳过处理") + return + else: + kick_map.pop(player, None) + + if is_bot_player(player): + server.logger.info(f"过滤假人 {player} 的退出事件 (匹配前缀: {bot_filter['prefixes']})") + return + + now = time.time() + last = exit_reported_at.get(player, 0) + if now - last < 2.0: + server.logger.debug(f"忽略重复退出上报: {player}") + return + exit_reported_at[player] = now + + server.logger.debug(f"正常玩家 {player} 退出事件处理") + await wsc.push_exit(player) + + +async def _report_player_exit(server: PluginServerInterface, name: str): + from easybot_mcdr.main import wsc, is_bot_player, kick_map, exit_reported_at, debounce_time + + if name in kick_map: + if time.time() - kick_map[name] < 15: + server.logger.debug(f"玩家 {name} 是被踢出的,退出事件上报已跳过") + return + else: + kick_map.pop(name, None) + + if is_bot_player(name): + server.logger.info(f"过滤假人 {name} 的退出事件") + return + + now = time.time() + last = exit_reported_at.get(name, 0) + if now - last < debounce_time: + server.logger.debug(f"忽略重复退出上报: {name}") + return + exit_reported_at[name] = now + + try: + await wsc.push_exit(name) + server.logger.debug(f"已上报玩家退出: {name}") + except Exception as e: + server.logger.error(f"上报玩家 {name} 退出失败: {e}") + import traceback + server.logger.debug(f"{traceback.format_exc()}") + + +async def on_info(server, info: Info): + raw = info.raw_content + from easybot_mcdr.main import wsc, is_bot_player + + if match := re.search( + r"UUID of player ([\w.]+) is ([0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})", + raw, + ): + name = match.group(1) + uuid = match.group(2).lower() + + if not is_bot_player(name): + from easybot_mcdr.api.player import update_player_uuid + update_player_uuid(name, uuid) + server.logger.info(f"从服务器获取到玩家 {name} 的正版UUID: {uuid}") + return + + m_join_pref = re.search(r"^\[[^\]]+\](?P[\w.]+) joined the game$", raw) + m_join_plain = re.search(r"^(?P[\w.]+) joined the game$", raw) + if m_join_pref or m_join_plain: + name = (m_join_pref or m_join_plain).group('name') + + if is_bot_player(name): + server.logger.info(f"检测到假人 {name},跳过UUID处理") + return + + from easybot_mcdr.api.player import uuid_map, generate_offline_uuid, update_player_uuid, online_players, cached_data, PlayerInfo + from easybot_mcdr.impl.get_server_info import get_online_mode + + current_uuid = uuid_map.get(name) + if not current_uuid or current_uuid == "unknown": + if not get_online_mode(): + correct_uuid = generate_offline_uuid(name) + update_player_uuid(name, correct_uuid) + server.logger.info(f"修正玩家 {name} 的离线UUID: {correct_uuid}") + + try: + ip = "127.0.0.1" + if match_ip := re.search(r"\d+\.\d+\.\d+\.\d+", raw): + ip = match_ip.group() + if name not in online_players: + online_players[name] = PlayerInfo(ip, name, uuid_map.get(name, "unknown")) + cached_data[name] = online_players[name] + except Exception as e: + server.logger.warning(f"写入玩家 {name} 本地缓存失败: {e}") + + from easybot_mcdr.utils import is_white_list_enable + if is_white_list_enable(): + try: + bind_info = await wsc.get_social_account(name) + if bind_info and bind_info.get("uuid"): + server.execute(f"whitelist add {name}") + except Exception as e: + server.logger.error(f"获取玩家 {name} 绑定信息失败: {str(e)}") + import traceback + server.logger.debug(f"{traceback.format_exc()}") + return + + m_quit = re.search(r"(?:\[[^\]]+\])?(?P[\w.]+) left the game", raw) + if m_quit: + name = m_quit.group('name') + server.logger.debug(f"检测到退出行,解析玩家: {name} | 原始: {raw}") + await _report_player_exit(server, name) + return + + m_lost = re.search(r"(?:\[[^\]]+\])?(?P[\w.]+) lost connection:\s*", raw) + if m_lost: + name = m_lost.group('name') + server.logger.debug(f"检测到断开行,解析玩家: {name} | 原始: {raw}") + await _report_player_exit(server, name) + return + + +async def on_player_death(server: PluginServerInterface, player: str, killer: str = None): + from easybot_mcdr.main import is_bot_player + from easybot_mcdr.config import get_config + + config = get_config() + bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) + server.logger.debug(f"处理玩家死亡事件: {player}, 假人过滤状态: enabled={bot_filter['enabled']}") + + if is_bot_player(player): + server.logger.info(f"过滤假人 {player} 的死亡事件 (匹配前缀: {bot_filter['prefixes']})") + return + server.logger.debug(f"正常玩家 {player} 死亡事件处理") + + +def _push_kick(player: str, reason: str): + from easybot_mcdr.main import kick_map + if reason is None or reason.strip() == "": + reason = "你已被踢出服务器" + server = ServerInterface.get_instance() + kick_map[player] = time.time() + if not server.is_rcon_running(): + server.logger.error("你的服务器RCON当前并未运行,踢出玩家的原因无法显示多行。") + server.logger.error(f"即将踢出玩家 {player} 并且只显示踢出原因的第一行!") + first_line = reason.split("\n")[0] + server.execute(f"kick {player} {first_line}") + return + server.rcon_query(f"kick {player} {reason}") + kick_map[player] = time.time() + + +# RCON 未配置提示冷却: {player: last_notify_time} +_rcon_notify_cooldown = {} +_RCON_NOTIFY_INTERVAL = 300 # 5分钟内不重复提示同一玩家 + + +def notify_rcon_not_configured(server: PluginServerInterface, player: str): + """当 RCON 未配置时,在游戏内提示管理员""" + now = time.time() + last = _rcon_notify_cooldown.get(player, 0) + if now - last < _RCON_NOTIFY_INTERVAL: + return + + if server.is_rcon_running(): + return + + from easybot_mcdr.rcon_config import check_rcon_config + status = check_rcon_config(server) + if not status["needs_config"]: + return + + # 只通知权限等级 >= 2 的玩家(管理员) + try: + perm_level = server.get_permission_level(player) + if perm_level >= 2: + server.tell(player, "§e[EasyBot] §cRCON 未配置! 命令执行、玩家皮肤获取等功能受限。") + server.tell(player, "§e[EasyBot] 使用 §b!!ez rcon auto §e自动配置RCON") + _rcon_notify_cooldown[player] = now + except Exception: + pass diff --git a/easybot_mcdr/impl/player_list.py b/easybot_mcdr/impl/player_list.py index 0a7bce6..10dcbc6 100644 --- a/easybot_mcdr/impl/player_list.py +++ b/easybot_mcdr/impl/player_list.py @@ -1,13 +1,61 @@ -from easybot_mcdr.impl.get_server_info import get_online_mode +import asyncio +import time +import requests +from easybot_mcdr.impl.get_server_info import get_online_mode, get_skins_restorer from easybot_mcdr.websocket.context import ExecContext from easybot_mcdr.websocket.ws import EasyBotWsClient from mcdreforged.api.all import * -def try_get_skin(name): - if get_online_mode(): # 只有在线模式获取到的皮肤才是正确的 +# Mojang 正版检测缓存: {name: (is_premium, timestamp)} +_premium_cache = {} +_PREMIUM_CACHE_TTL = 3600 # 1小时 + + +def _check_premium_sync(name: str) -> bool: + """同步查询 Mojang API 判断是否为正版账户""" + try: + resp = requests.get( + f"https://api.mojang.com/users/profiles/minecraft/{name}", + timeout=5 + ) + return resp.status_code == 200 + except Exception: + return False + + +async def check_premium(name: str) -> bool: + """异步查询 Mojang API,带缓存""" + now = time.time() + if name in _premium_cache: + is_premium, ts = _premium_cache[name] + if now - ts < _PREMIUM_CACHE_TTL: + return is_premium + loop = asyncio.get_event_loop() + is_premium = await loop.run_in_executor(None, _check_premium_sync, name) + _premium_cache[name] = (is_premium, now) + return is_premium + + +async def try_get_skin(name, uuid=""): + online = get_online_mode() + has_sr = get_skins_restorer() + + if online: + return f"https://mineskin.eu/download/{name}" + + if has_sr: return f"https://mineskin.eu/download/{name}" - # 默认尼哥 - return "https://textures.minecraft.net/texture/eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664" + + # 离线模式无皮肤站: 查询 Mojang 判断是否正版 + is_premium = await check_premium(name) + if is_premium: + # 正版账户: mineskin 能获取到正确皮肤 + return f"https://mineskin.eu/download/{name}" + + # 非正版: 使用 mc-heads.net 头像 + if uuid: + return f"https://mc-heads.net/skin/{uuid}" + return "" @EasyBotWsClient.listen_exec_op("PLAYER_LIST") async def on_get_player_list(ctx: ExecContext, data:dict, _): @@ -16,12 +64,13 @@ async def on_get_player_list(ctx: ExecContext, data:dict, _): online_list = get_player_list() list = [] for player in online_list: + skin_url = await try_get_skin(player, online_list[player].uuid) list.append({ "player_name": player, "player_uuid": online_list[player].uuid, "ip": online_list[player].ip, "bedrock": False, - "skin_url": try_get_skin(player) + "skin_url": skin_url }) await ctx.callback({ "list": list diff --git a/easybot_mcdr/impl/prefix_handler.py b/easybot_mcdr/impl/prefix_handler.py index 2e855d5..984f6f1 100644 --- a/easybot_mcdr/impl/prefix_handler.py +++ b/easybot_mcdr/impl/prefix_handler.py @@ -1,85 +1,57 @@ import re -from typing import List, Type +from mcdreforged.handler.impl.vanilla_handler import VanillaHandler -# Dynamically collect available handlers in a preferred order -_handler_classes: List[Type] = [] -try: - from mcdreforged.handler.impl import ForgeHandler - _handler_classes.append(ForgeHandler) -except Exception: - pass -try: - from mcdreforged.handler.impl import FabricHandler - _handler_classes.append(FabricHandler) -except Exception: - pass -try: - # Some environments only expose Bukkit/Paper via Spigot-like handler - from mcdreforged.handler.impl import SpigotHandler - _handler_classes.append(SpigotHandler) -except Exception: - pass -try: - from mcdreforged.handler.impl import PaperHandler - _handler_classes.append(PaperHandler) -except Exception: - pass -try: - from mcdreforged.handler.impl import VanillaHandler - _handler_classes.append(VanillaHandler) -except Exception: - pass -# Final fallback: if nothing imported, raise at runtime clearly -if not _handler_classes: - raise ImportError('No base handlers available from mcdreforged.handler.impl') - - -class PrefixNameHandler(_handler_classes[0]): - """ - A server handler that parses chat lines with player name prefixes, e.g. - "<[Builder]Steve> Hello" -> player="Steve", content="Hello" - - Composite approach: try multiple base handlers (Forge/Fabric/Spigot/Paper/Vanilla) - in order to maximize parse success across server types. If none recognize player, - apply prefix post-processing. - """ +class PrefixNameHandler(VanillaHandler): + """复合服务器处理器:尝试多种内置 handler 解析,支持前缀格式玩家名""" def get_name(self) -> str: return 'easybot_prefix_handler' + def _get_handler_classes(self): + classes = [] + for name in ('ForgeHandler', 'FabricHandler', 'SpigotHandler', + 'PaperHandler', 'VanillaHandler'): + try: + mod = __import__('mcdreforged.handler.impl', + fromlist=[name]) + classes.append(getattr(mod, name)) + except (ImportError, AttributeError): + continue + return classes + def parse_server_stdout(self, text: str): - # Try each underlying handler until one yields a result + handlers = self._get_handler_classes() info = None - last_info = None - for cls in _handler_classes: - parser = getattr(self, f'_eb_{cls.__name__}', None) + for cls in handlers: + attr = f'_eb_{cls.__name__}' + parser = getattr(self, attr, None) if parser is None: try: parser = cls() except Exception: continue - setattr(self, f'_eb_{cls.__name__}', parser) + setattr(self, attr, parser) try: - last_info = parser.parse_server_stdout(text) - if last_info is not None: - info = last_info - # Prefer the first one that recognizes a player name - if getattr(info, 'player', None): + result = parser.parse_server_stdout(text) + if result is not None: + info = result + if getattr(result, 'player', None): break except Exception: continue if info is None: - # As a last resort, call our own base implementation (first class) info = super().parse_server_stdout(text) - # Only try to parse when no parser recognized a player + # 前缀解析: <[Prefix]PlayerName> Message if info.player is None: - # Match like: [Not Secure] <[AnyWord]PlayerName> Message or <[AnyWord]PlayerName> Message - # prefix group is optional capture for readability; only name+message used - m = re.fullmatch(r'(?:\[Not Secure\] )?<\[(?P[^\]]+)\](?P[^>]+)> (?P.*)', info.content) - if m is not None and self._verify_player_name(m['name']): + m = re.fullmatch( + r'(?:\[Not Secure\] )?<\[(?P[^\]]+)\]' + r'(?P[^>]+)> (?P.*)', + info.content + ) + if m and self._verify_player_name(m['name']): info.player = m['name'] info.content = m['message'] return info diff --git a/easybot_mcdr/impl/rpc_handlers.py b/easybot_mcdr/impl/rpc_handlers.py index aa5df09..cac8f0e 100644 --- a/easybot_mcdr/impl/rpc_handlers.py +++ b/easybot_mcdr/impl/rpc_handlers.py @@ -16,17 +16,13 @@ async def ping(ctx, data, session_info): await ctx.callback({"success": True, "text": "pong"}) -@bridge_rpc("GET_SERVER_INFO", description="Get server basic info") -async def get_server_info(ctx, data, session_info): - await ctx.callback({"success": True, "info": _behavior().get_info()}) +@bridge_rpc("GET_EXTENSIONS", description="Get installed extensions") +async def get_extensions(ctx, data, session_info): + await ctx.callback({"success": True, "extensions": {}}) @bridge_rpc("SYNC_SEGMENTS", description="Sync structured message to chat") async def sync_segments(ctx, data, session_info): - """ - 接收 segments 和 fallback text,将其投递到聊天。 - segments: list of dict, text: str - """ segments = segments_from_list(data.get("segments", []) or []) text = data.get("text", "") _behavior().sync_to_chat_extra(segments, text) @@ -37,7 +33,7 @@ async def sync_segments(ctx, data, session_info): async def run_command(ctx, data, session_info): player = data.get("player_name") or "" command = data.get("command") or "" - enable_papi = False # PAPI 已移除 + enable_papi = False try: result = _behavior().run_command(player, command, enable_papi) await ctx.callback({"success": True, "text": result}) @@ -91,3 +87,28 @@ async def get_player_list(ctx, data, session_info): await ctx.callback({"success": True, "players": players}) except Exception as e: await ctx.callback({"success": False, "text": str(e)}) + + +@bridge_rpc("MODULE_INSTALLED", description="Check if a module/plugin is installed") +async def module_installed(ctx, data, session_info): + name = data.get("name") or "" + await ctx.callback({"success": True, "result": _behavior().module_is_installed(name)}) + + +@bridge_rpc("MODULE_ENABLED", description="Check if a module/plugin is enabled") +async def module_enabled(ctx, data, session_info): + name = data.get("name") or "" + await ctx.callback({"success": True, "result": _behavior().module_is_enabled(name)}) + + +@bridge_rpc("IS_AUTHENTICATED", description="Check if a player is authenticated/bound") +async def is_authenticated(ctx, data, session_info): + player_name = data.get("player_name") or "" + await ctx.callback({"success": True, "result": _behavior().is_authenticated(player_name)}) + + +@bridge_rpc("GET_PLAYER_SKIN", description="Get player skin URL") +async def get_player_skin(ctx, data, session_info): + player_name = data.get("player_name") or "" + skin_url = _behavior().get_player_skin(player_name) + await ctx.callback({"success": True, "skin_url": skin_url or ""}) diff --git a/easybot_mcdr/impl/un_bind_notify.py b/easybot_mcdr/impl/un_bind_notify.py index e0a8f02..9a4bb91 100644 --- a/easybot_mcdr/impl/un_bind_notify.py +++ b/easybot_mcdr/impl/un_bind_notify.py @@ -29,5 +29,5 @@ async def exec_un_bind_notify(ctx: ExecContext, data: dict, _): except Exception as e: logger.error(f"移除白名单失败: {e}") - from easybot_mcdr.main import push_kick - push_kick(player_name, "您已从聊群或管理平台解除账户绑定") + from easybot_mcdr.impl.player_events import _push_kick + _push_kick(player_name, "您已从聊群或管理平台解除账户绑定") diff --git a/easybot_mcdr/main.py b/easybot_mcdr/main.py index e41fad0..ba394fd 100644 --- a/easybot_mcdr/main.py +++ b/easybot_mcdr/main.py @@ -20,7 +20,8 @@ player_data_map = {} rcon_initialized = False exit_reported_at = {} -debounce_time = 5 +debounce_time = 5 +kick_map = {} from easybot_mcdr.meta import get_plugin_version from easybot_mcdr.rpc import bind_registered_handlers @@ -39,6 +40,10 @@ §b!!esay §f- §c同上 §b!!say §f- §c同上 +§cRCON配置(需MCDR 3级权限及以上) +§b!!ez rcon auto §f- §c自动配置RCON +§b!!ez rcon status §f- §c查看RCON状态 + §c假人过滤设置(需MCDR 3级权限及以上) §b!!ez bot toggle §f- §c开启/关闭假人过滤 §b!!ez bot add §f- §c添加假人过滤前缀 @@ -68,9 +73,19 @@ async def on_load(server: PluginServerInterface, prev_module): try: # 加载配置 load_config(server) - + + # 启动本地图片服务器(如果启用了图片上传) + if get_config().get("image_upload", {}).get("enabled"): + from easybot_mcdr.impl.chat_image import start_local_image_server + start_local_image_server() + # 注册服务器处理器 - server.register_server_handler(PrefixNameHandler()) + config = get_config() + if config.get("handler", {}).get("enabled", True): + server.register_server_handler(PrefixNameHandler()) + server.logger.info("已启用自定义前缀处理器") + else: + server.logger.info("使用 MCDR 默认处理器") # 启动UUID检查线程 start_uuid_check_thread(server) @@ -89,6 +104,14 @@ async def on_load(server: PluginServerInterface, prev_module): # 注册命令 register_commands(server) + # 检查 RCON 配置 + if not server.is_rcon_running(): + from easybot_mcdr.rcon_config import check_rcon_config + status = check_rcon_config(server) + if status["needs_config"]: + server.logger.warning("§cRCON 未配置! 部分功能(命令执行、玩家皮肤获取)可能受限。") + server.logger.warning("§e使用 §b!!ez rcon auto §e自动配置RCON") + server.logger.info("EasyBot插件加载完成") except Exception as e: server.logger.error(f"插件加载过程中发生错误: {str(e)}") @@ -346,8 +369,11 @@ async def show_plugin_info(source: CommandSource): async def on_unload(server: PluginServerInterface): global player_data_map, wsc, server_interface - + try: + # 停止本地图片服务器 + from easybot_mcdr.impl.chat_image import stop_local_image_server + stop_local_image_server() # 在关闭连接前,上报服务器关闭(仅在已连接时尝试) if wsc: try: @@ -524,20 +550,19 @@ async def initialize_websocket_client(server: PluginServerInterface): def register_event_listeners(server: PluginServerInterface): """注册事件监听器""" + from easybot_mcdr.impl.player_events import on_player_joined, on_player_left, on_player_death, on_info + from easybot_mcdr.impl.chat_sync import on_user_info + server.logger.info("注册事件监听器...") - - # 注册服务器启动事件(使用较低优先级以避免与其他插件冲突) + server.register_event_listener('server_started', on_server_started, priority=50) - - # 注册信息事件处理 server.register_event_listener('mcdr.general_info', on_info, priority=1) - - # 注册玩家相关事件 server.register_event_listener('player_death', on_player_death) server.register_event_listener('mcdr.player_left', on_player_left) server.register_event_listener('player_left', on_player_left) server.register_event_listener('player_joined', on_player_joined) - + server.register_event_listener('mcdr.user_info', on_user_info) + server.logger.info("事件监听器注册完成") def register_commands(server: PluginServerInterface): @@ -557,6 +582,10 @@ def register_commands(server: PluginServerInterface): builder.command("!!esay ", say) builder.command("!!ez say ", say) + # RCON 配置命令 + builder.command("!!ez rcon auto", rcon_auto) + builder.command("!!ez rcon status", rcon_status) + # 假人过滤命令 builder.command("!!ez bot toggle", toggle_bot_filter) builder.command("!!ez bot add ", add_bot_prefix) @@ -614,23 +643,48 @@ async def say(source: CommandSource, context: CommandContext): await wsc.push_message(name, context["message"], True) source.reply("§a消息已发送: §f" + context["message"]) -kick_map = {} - -def push_kick(player: str, reason: str): - global kick_map - if reason is None or reason.strip() == "": - reason = "你已被踢出服务器" - server = ServerInterface.get_instance() - # 记录当前时间戳,而不是简单的 append - kick_map[player] = time.time() - if not server.is_rcon_running(): - server.logger.error("你的服务器RCON当前并未运行,踢出玩家的原因无法显示多行。") - server.logger.error(f"即将踢出玩家 {player} 并且只显示踢出原因的第一行!") - first_line = reason.split("\n")[0] - server.execute(f"kick {player} {first_line}") +async def rcon_auto(source: CommandSource): + """自动配置RCON""" + if not source.has_permission(3): + source.reply("§c你没有权限使用这个命令!") + return + from easybot_mcdr.rcon_config import check_rcon_config, auto_configure_rcon + server = source.get_server() + status = check_rcon_config(server) + if status["rcon_enabled"] and server.is_rcon_running(): + source.reply(f"§aRCON 已配置并运行中! (端口: {status['rcon_port']})") return - server.rcon_query(f"kick {player} {reason}") - kick_map[player] = time.time() + source.reply("§e正在自动配置RCON...") + success = auto_configure_rcon(server) + if success: + new_status = check_rcon_config(server) + if server.is_rcon_running(): + source.reply(f"§aRCON 配置已同步! (端口: {new_status['rcon_port']})") + else: + source.reply(f"§aRCON 自动配置完成! 端口: §f{new_status['rcon_port']}") + source.reply("§e请重启服务器生效。") + else: + source.reply("§cRCON 自动配置失败,请检查日志。") + +async def rcon_status(source: CommandSource): + """查看RCON状态""" + from easybot_mcdr.rcon_config import check_rcon_config, test_rcon_connection + status = check_rcon_config(source.get_server()) + running = test_rcon_connection(source.get_server()) + port_ok = "§a是" if not status["port_mismatch"] else f"§c否 (MCDR:{status['rcon_port']} vs 服务端:{status['server_rcon_port']})" + lines = [ + '--------§a RCON 状态 §r--------', + f'§bMCDR RCON: §f{"§a启用" if status["mcdr_rcon_enabled"] else "§c禁用"}', + f'§b服务端 RCON: §f{"§a启用" if status["server_rcon_enabled"] else "§c禁用"}', + f'§bRCON 端口: §f{status["rcon_port"]}', + f'§b端口一致: §f{port_ok}', + f'§bRCON 连接: §f{"§a正常" if running else "§c未连接"}', + '---------------------------------------------' + ] + if status["needs_config"]: + lines.append("§e提示: 使用 §b!!ez rcon auto §e自动配置RCON") + for line in lines: + source.reply(line) async def toggle_bot_filter(source: CommandSource): if not source.has_permission(3): @@ -684,165 +738,6 @@ async def list_bot_prefixes(source: CommandSource): source.reply(f"§a假人过滤状态: {state}") source.reply("§a假人前缀列表: " + ", ".join(prefixes) if prefixes else "§c无前缀") -async def on_player_joined(server: PluginServerInterface, player: str, info: Info): - try: - from easybot_mcdr.api.player import cached_data - - config = get_config() - bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) - server.logger.debug(f"假人过滤配置: enabled={bot_filter['enabled']}, prefixes={bot_filter['prefixes']}") - - if is_bot_player(player): - ip = "unknown" - if match := re.search(r'\d+\.\d+\.\d+\.\d+', info.raw_content): - ip = match.group() - player_info = cached_data.get(player) - uuid = player_info.uuid if player_info else "unknown" - server.logger.info(f"检测到假人 {player} (匹配前缀: {bot_filter['prefixes']}), UUID={uuid}, IP={ip}") - return - - player_info = await wsc.report_player(player) - if player_info is None: - server.logger.warning(f"玩家 {player} 的信息未准备好,可能是数据同步延迟") - return - server.logger.info(f"玩家 {player} 已加入并缓存: UUID={player_info['player_uuid']}, IP={player_info['ip']}") - res = await wsc.login(player) - if res["kick"]: - kick_msg = res.get("kick_message", "验证失败") - server.logger.info(f"检测到玩家 {player} 需要被踢出,等待加载延迟...") - # 记录到字典中,使用时间戳 - kick_map[player] = time.time() - # 1. 标记为踢出,防止触发退出播报 - if player not in kick_map: - kick_map[player] = time.time() - - # 2. 在踢出前发送聊天栏通知 - # 使用红色文字 (§c) 醒目提示 - server.tell(player, f"§c[EasyBot] 验证未通过: {kick_msg}") - server.tell(player, "§c[EasyBot] 您将在 5 秒后被移出服务器,请按照提示操作。") - - # 3. 等待并执行踢出(可配置) - kick_delay = get_config().get("kick_delay_seconds", 5) - await asyncio.sleep(kick_delay) - push_kick(player, kick_msg) - return - - if player in kick_map: - kick_map.pop(player) - - await wsc.push_enter(player) - - except Exception as e: - server.logger.error(f"处理玩家 {player} 加入时出错: {e}") - server.logger.debug("\n{traceback.format_exc()}") - -# 统一的玩家退出上报函数 -async def _report_player_exit(server: PluginServerInterface, name: str): - # 踢出列表过滤 - if name in kick_map: - if time.time() - kick_map[name] < 15: - server.logger.debug(f"玩家 {name} 是被踢出的,退出事件上报已跳过") - return - else: - # 如果超过15秒残留的标记,清理掉,继续执行 - kick_map.pop(name, None) - - # 假人过滤 - if is_bot_player(name): - server.logger.info(f"过滤假人 {name} 的退出事件") - return - - # 去重 - now = time.time() - last = exit_reported_at.get(name, 0) - if now - last < debounce_time: - server.logger.debug(f"忽略重复退出上报: {name}") - return - exit_reported_at[name] = now - - try: - await wsc.push_exit(name) - server.logger.debug(f"已上报玩家退出: {name}") - except Exception as e: - server.logger.error(f"上报玩家 {name} 退出失败: {e}") - server.logger.debug("\n{traceback.format_exc()}") - -async def on_info(server, info: Info): - raw = info.raw_content - - # 正版UUID处理 - if match := re.search( - r"UUID of player ([\w.]+) is ([0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})", - raw, - ): - name = match.group(1) - uuid = match.group(2).lower() - - if not is_bot_player(name): - from easybot_mcdr.api.player import update_player_uuid - update_player_uuid(name, uuid) - server.logger.info(f"从服务器获取到玩家 {name} 的正版UUID: {uuid}") - return - - # 玩家加入消息处理(用于离线模式UUID同步验证,兼容含前缀名称) - m_join_pref = re.search(r"^\[[^\]]+\](?P[\w.]+) joined the game$", raw) - m_join_plain = re.search(r"^(?P[\w.]+) joined the game$", raw) - if m_join_pref or m_join_plain: - name = (m_join_pref or m_join_plain).group('name') - - if is_bot_player(name): - server.logger.info(f"检测到假人 {name},跳过UUID处理") - return - - # 确保UUID已正确设置(双重检查) - from easybot_mcdr.api.player import uuid_map, generate_offline_uuid, update_player_uuid, online_players, cached_data, PlayerInfo - - current_uuid = uuid_map.get(name) - if not current_uuid or current_uuid == "unknown": - # 生成或修正UUID - if not get_online_mode(): - correct_uuid = generate_offline_uuid(name) - update_player_uuid(name, correct_uuid) - server.logger.info(f"修正玩家 {name} 的离线UUID: {correct_uuid}") - - # 在本地缓存玩家信息(供后续上报退出等使用) - try: - ip = "127.0.0.1" - if match_ip := re.search(r"\d+\.\d+\.\d+\.\d+", raw): - ip = match_ip.group() - # 若不存在则创建/更新 - if name not in online_players: - online_players[name] = PlayerInfo(ip, name, uuid_map.get(name, "unknown")) - cached_data[name] = online_players[name] - except Exception as e: - server.logger.warning(f"写入玩家 {name} 本地缓存失败: {e}") - - # 白名单处理 - if is_white_list_enable(): - try: - bind_info = await wsc.get_social_account(name) - if bind_info and bind_info.get("uuid"): - server.execute(f"whitelist add {name}") - except Exception as e: - server.logger.error(f"获取玩家 {name} 绑定信息失败: {str(e)}") - server.logger.debug("\n{traceback.format_exc()}") - return - - # 玩家退出消息处理(兼容含前缀名称与额外前后缀文本) - m_quit = re.search(r"(?:\[[^\]]+\])?(?P[\w.]+) left the game", raw) - if m_quit: - name = m_quit.group('name') - server.logger.debug(f"检测到退出行,解析玩家: {name} | 原始: {raw}") - await _report_player_exit(server, name) - return - - # 兼容 "lost connection:" 形式(有些服务端不打印 left the game) - m_lost = re.search(r"(?:\[[^\]]+\])?(?P[\w.]+) lost connection:\s*", raw) - if m_lost: - name = m_lost.group('name') - server.logger.debug(f"检测到断开行,解析玩家: {name} | 原始: {raw}") - await _report_player_exit(server, name) - return # 新增:定期UUID同步检查函数 @new_thread("UUID_Sync_Check") @@ -878,60 +773,3 @@ def periodic_uuid_check(): server = ServerInterface.get_instance() server.logger.error(f"UUID同步检查出错: {e}") server.logger.debug("\n{traceback.format_exc()}") - - -async def on_player_death(server: PluginServerInterface, player: str, killer: str = None): - config = get_config() - bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) - server.logger.debug(f"处理玩家死亡事件: {player}, 假人过滤状态: enabled={bot_filter['enabled']}") - - if is_bot_player(player): - server.logger.info(f"过滤假人 {player} 的死亡事件 (匹配前缀: {bot_filter['prefixes']})") - return - server.logger.debug(f"正常玩家 {player} 死亡事件处理") - -async def on_player_left(server: PluginServerInterface, player: str): - config = get_config() - bot_filter = config.get("bot_filter", {"enabled": True, "prefixes": ["Bot_", "BOT_", "bot_"]}) - server.logger.debug(f"处理玩家退出事件: {player}, 假人过滤状态: enabled={bot_filter['enabled']}") - - if player in kick_map: - if time.time() - kick_map[player] < 15: - server.logger.debug(f"玩家 {player} 是被踢出的,跳过处理") - return - else: - kick_map.pop(player, None) - - if is_bot_player(player): - server.logger.info(f"过滤假人 {player} 的退出事件 (匹配前缀: {bot_filter['prefixes']})") - return - - # 避免与 on_info 中的解析重复上报 - now = time.time() - last = exit_reported_at.get(player, 0) - if now - last < 2.0: - server.logger.debug(f"忽略重复退出上报: {player}") - return - exit_reported_at[player] = now - - server.logger.debug(f"正常玩家 {player} 退出事件处理") - await wsc.push_exit(player) - -async def on_user_info(server: PluginServerInterface, info: Info): - if info.player is None: - return - if ( - info.content.startswith("!!") - and get_config()["message_sync"]["ignore_mcdr_command"] - ): - return - await wsc.push_message(info.player, info.content, False) - -async def cross_server_say(source: CommandSource, context: CommandContext): - if not source.is_player: - source.reply("§c这个命令只能由玩家使用!") - return - player = source.player - message = context["message"] - await wsc.push_cross_server_message(player, message) - source.reply("§a你的消息已发送到其他服务器.") diff --git a/easybot_mcdr/message.py b/easybot_mcdr/message.py index 23c4ae2..0698a92 100644 --- a/easybot_mcdr/message.py +++ b/easybot_mcdr/message.py @@ -1,35 +1,37 @@ from dataclasses import dataclass -from enum import Enum +from enum import IntEnum from typing import Any, Dict, List, Optional -class SegmentType(str, Enum): - TEXT = "text" - IMAGE = "image" - FILE = "file" - AT = "at" - REPLY = "reply" - UNKNOWN = "unknown" +class SegmentType(IntEnum): + UNKNOWN = 1 + TEXT = 2 + IMAGE = 3 + AT = 4 + FILE = 5 + REPLY = 6 + FACE = 7 @dataclass class Segment: - type: SegmentType + type: int def to_dict(self) -> Dict[str, Any]: - d = {"type": self.type} + d = {"type": int(self.type)} d.update({k: v for k, v in self.__dict__.items() if k != "type"}) return d @staticmethod def from_dict(data: Dict[str, Any]) -> "Segment": - seg_type = SegmentType(data.get("type", SegmentType.UNKNOWN)) + seg_type = data.get("type", SegmentType.UNKNOWN) mapping = { SegmentType.TEXT: TextSegment, SegmentType.IMAGE: ImageSegment, SegmentType.FILE: FileSegment, SegmentType.AT: AtSegment, SegmentType.REPLY: ReplySegment, + SegmentType.FACE: FaceSegment, } cls = mapping.get(seg_type, UnknownSegment) return cls(**{k: v for k, v in data.items() if k != "type"}) @@ -39,7 +41,7 @@ def from_dict(data: Dict[str, Any]) -> "Segment": class TextSegment(Segment): text: str - def __init__(self, text: str): + def __init__(self, text: str, **_kw): super().__init__(SegmentType.TEXT) self.text = text @@ -47,30 +49,37 @@ def __init__(self, text: str): @dataclass class ImageSegment(Segment): url: str + summary: Optional[str] = None - def __init__(self, url: str): + def __init__(self, url: str, summary: Optional[str] = None, **_kw): super().__init__(SegmentType.IMAGE) self.url = url + self.summary = summary @dataclass class FileSegment(Segment): - url: str + file_url: str name: Optional[str] = None - def __init__(self, url: str, name: Optional[str] = None): + def __init__(self, file_url: str = "", url: str = "", name: Optional[str] = None, **_kw): super().__init__(SegmentType.FILE) - self.url = url + self.file_url = file_url or url self.name = name @dataclass class AtSegment(Segment): - target: str + at_user_name: str + at_user_id: str + at_player_names: List[str] - def __init__(self, target: str): + def __init__(self, at_user_name: str = "", at_user_id: str = "", + at_player_names: Optional[List[str]] = None, target: str = "", **_kw): super().__init__(SegmentType.AT) - self.target = target + self.at_user_name = at_user_name or target + self.at_user_id = at_user_id + self.at_player_names = at_player_names or [] @dataclass @@ -78,12 +87,23 @@ class ReplySegment(Segment): message_id: str text: Optional[str] = None - def __init__(self, message_id: str, text: Optional[str] = None): + def __init__(self, message_id: str = "", text: Optional[str] = None, **_kw): super().__init__(SegmentType.REPLY) self.message_id = message_id self.text = text +@dataclass +class FaceSegment(Segment): + id: Optional[int] = None + display_name: Optional[str] = None + + def __init__(self, id: Optional[int] = None, display_name: Optional[str] = None, **_kw): + super().__init__(SegmentType.FACE) + self.id = id + self.display_name = display_name + + @dataclass class UnknownSegment(Segment): raw: Dict[str, Any] diff --git a/easybot_mcdr/rcon_config.py b/easybot_mcdr/rcon_config.py new file mode 100644 index 0000000..b1342e7 --- /dev/null +++ b/easybot_mcdr/rcon_config.py @@ -0,0 +1,198 @@ +import os +import random +import socket +import logging + +logger = logging.getLogger("EasyBot") + +_server_dir = None + + +def _get_server_dir() -> str: + global _server_dir + if _server_dir: + return _server_dir + try: + from mcdreforged.api.types import ServerInterface + server = ServerInterface.psi() + _server_dir = server.get_mcdr_config().get("working_directory", ".") + except Exception: + _server_dir = "." + return _server_dir + + +def _get_server_prop_path() -> str: + return os.path.join(_get_server_dir(), "server.properties") + + +def _is_port_available(port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + return s.connect_ex(("localhost", port)) != 0 + except Exception: + return False + + +def get_available_port(port: int) -> int: + if _is_port_available(port): + return port + for _ in range(50): + new_port = port + random.randint(-100, 100) + if 1024 <= new_port <= 65535 and _is_port_available(new_port): + return new_port + return port + + +def read_server_properties() -> dict: + props = {} + prop_path = _get_server_prop_path() + if not os.path.exists(prop_path): + return props + try: + with open(prop_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + props[key.strip()] = value.strip() + except Exception as e: + logger.error(f"读取 server.properties 失败: {e}") + return props + + +def write_server_properties(props: dict): + try: + prop_path = _get_server_prop_path() + lines = [] + for key, value in props.items(): + lines.append(f"{key}={value}") + with open(prop_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + logger.info("server.properties 已更新") + except Exception as e: + logger.error(f"写入 server.properties 失败: {e}") + + +def check_rcon_config(server) -> dict: + result = { + "rcon_enabled": False, + "rcon_port": 25575, + "rcon_password": "", + "mcdr_rcon_enabled": False, + "server_rcon_enabled": False, + "server_rcon_port": 25575, + "port_mismatch": False, + "needs_config": False, + } + + try: + mcdr_config = server.get_mcdr_config() + rcon_cfg = mcdr_config.get("rcon", {}) + result["mcdr_rcon_enabled"] = rcon_cfg.get("enable", False) + result["rcon_port"] = rcon_cfg.get("port", 25575) + result["rcon_password"] = rcon_cfg.get("password", "") + except Exception: + pass + + props = read_server_properties() + result["server_rcon_enabled"] = props.get("enable-rcon", "false").lower() == "true" + result["server_rcon_port"] = int(props.get("rcon.port", 25575)) + + result["rcon_enabled"] = result["mcdr_rcon_enabled"] and result["server_rcon_enabled"] + + if result["rcon_enabled"] and result["rcon_port"] != result["server_rcon_port"]: + result["port_mismatch"] = True + result["rcon_enabled"] = False + + if not result["rcon_enabled"]: + result["needs_config"] = True + + return result + + +def test_rcon_connection(server) -> bool: + try: + result = server.rcon_query("list") + return result is not None + except Exception: + return False + + +def auto_configure_rcon(server, port: int = 25575, password: str = "") -> bool: + # 优先级1: RCON 已经在运行,直接同步当前连接信息到 MCDR + if server.is_rcon_running(): + mcdr_cfg = server.get_mcdr_config().get("rcon", {}) + mcdr_port = mcdr_cfg.get("port", 25575) + mcdr_password = mcdr_cfg.get("password", "") + try: + server.modify_mcdr_config({ + "rcon.enable": True, + "rcon.port": mcdr_port, + "rcon.password": mcdr_password, + }) + logger.info(f"RCON 已在运行,同步 MCDR 配置: port={mcdr_port}") + return True + except Exception as e: + logger.error(f"同步 RCON 配置失败: {e}") + return False + + props = read_server_properties() + props_rcon_enabled = props.get("enable-rcon", "").lower() == "true" + + # 优先级2: server.properties 已配置 RCON,同步到 MCDR + if props_rcon_enabled: + server_port = int(props.get("rcon.port", 25575)) + server_password = props.get("rcon.password", "") + + try: + server.modify_mcdr_config({ + "rcon.enable": True, + "rcon.port": server_port, + "rcon.password": server_password, + }) + logger.info(f"已从 server.properties 同步 RCON 配置到 MCDR: port={server_port}") + return True + except Exception as e: + logger.error(f"同步 RCON 配置失败: {e}") + return False + + # 优先级3: 完全未配置,自动生成 + try: + import secrets + if not password: + password = secrets.token_urlsafe(16) + + available_port = get_available_port(port) + + try: + server.modify_mcdr_config({ + "rcon.enable": True, + "rcon.port": available_port, + "rcon.password": password, + }) + logger.info(f"MCDR RCON 配置已更新: port={available_port}") + except Exception as e: + logger.error(f"更新 MCDR RCON 配置失败: {e}") + return False + + props["enable-rcon"] = "true" + props["rcon.port"] = str(available_port) + props["rcon.password"] = password + write_server_properties(props) + + logger.info(f"RCON 自动配置完成: port={available_port},请重启服务器生效") + return True + + except Exception as e: + logger.error(f"RCON 自动配置失败: {e}") + return False + + +def get_rcon_config_tips() -> str: + return ( + "RCON 未配置,部分功能(命令执行、NBT读取)可能受限。\n" + "请使用 !!ez rcon auto 自动配置。" + ) diff --git a/easybot_mcdr/rpc.py b/easybot_mcdr/rpc.py index 0fa8641..89eba88 100644 --- a/easybot_mcdr/rpc.py +++ b/easybot_mcdr/rpc.py @@ -14,15 +14,6 @@ def bridge_rpc(exec_op: str, description: str = "") -> Callable[[Callable[..., A def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: _registry[exec_op] = func - - # 延迟导入以避免循环依赖 - try: - from easybot_mcdr.websocket.ws import EasyBotWsClient - EasyBotWsClient.listen_exec_op(exec_op)(func) - except Exception: - # 如果还未加载 EasyBotWsClient,可以在运行时手动 bind_registered_handlers - pass - return func return decorator diff --git a/easybot_mcdr/websocket/__init__.py b/easybot_mcdr/websocket/__init__.py new file mode 100644 index 0000000..2aea8a6 --- /dev/null +++ b/easybot_mcdr/websocket/__init__.py @@ -0,0 +1,2 @@ +from .ws import EasyBotWsClient +from .context import ExecContext diff --git a/easybot_mcdr/websocket/ws.py b/easybot_mcdr/websocket/ws.py index 3f23536..7ce1654 100644 --- a/easybot_mcdr/websocket/ws.py +++ b/easybot_mcdr/websocket/ws.py @@ -2,6 +2,7 @@ from collections import defaultdict import json import time +import uuid from types import SimpleNamespace import websockets from typing import Optional, Dict, Any, Callable, List @@ -11,8 +12,9 @@ from easybot_mcdr.meta import get_plugin_version from easybot_mcdr.websocket.context import ExecContext + class SessionInfo: - def __init__(self, version: str, system: str, dotnet: str, session_id:str, token: str, interval: int): + def __init__(self, version: str, system: str, dotnet: str, session_id: str, token: str, interval: int): self.version = version self.system = system self.dotnet = dotnet @@ -21,6 +23,7 @@ def __init__(self, version: str, system: str, dotnet: str, session_id:str, token self.interval = interval self.server_name = None + @staticmethod def from_dict(data: dict): return SessionInfo( version=data["version"], @@ -30,258 +33,220 @@ def from_dict(data: dict): token=data["token"], interval=data["interval"] ) - + def get_version(self): return self.version - + def get_system(self): return self.system - + def get_dotnet(self): return self.dotnet - + def get_session_id(self): return self.session_id - + def get_token(self): return self.token - + def get_interval(self): return self.interval - + def set_server_name(self, server_name: str): self.server_name = server_name def get_server_name(self): return self.server_name + class EasyBotWsClient: _listeners = defaultdict(list) @classmethod def listen_exec_op(cls, exec_op: str): - """装饰器: 为指定 exec_op 注册处理器""" def decorator(func): cls._listeners[exec_op].append(func) return func return decorator def __init__(self, url, mcdr_server=None): - # 确保url是字符串格式 - self.ws_url = str(url) if url is not None else "" - self.mcdr_server = mcdr_server - self._conn_lock = asyncio.Lock() - self._ws = None - self._active = False - self._manual_stop = False - self._reconnect_base = 2 # 基础重连延迟(秒) - self._max_reconnect_interval = 60 # 最大重连间隔(秒) - self._max_reconnect_attempts = 30 # 最大重连尝试次数 - self._reconnect_attempts = 0 # 重连尝试次数 - self._last_error_log_time = 0 # 上次错误日志时间 - self._session_info = None - self._heartbeat_task = None - self._connection_task = None - self._pending_requests = {} - self._request_counter = 0 - + self.ws_url = str(url) if url is not None else "" + self.mcdr_server = mcdr_server + self._conn_lock = asyncio.Lock() + self._is_connecting = False + self._ws = None + self._active = False + self._manual_stop = False + self._reconnect_delay = 5 + self._max_reconnect_attempts = 30 + self._reconnect_attempts = 0 + self._session_info = None + self._heartbeat_task = None + self._connection_task = None + self._pending_requests: Dict[str, asyncio.Future] = {} + self._request_counter = 0 + async def is_connected(self): - """检查WebSocket是否已连接""" - return hasattr(self, '_ws') and self._ws is not None and hasattr(self._ws, 'state') and self._ws.state is websockets.State.OPEN + return (self._ws is not None + and hasattr(self._ws, 'state') + and self._ws.state is websockets.State.OPEN) + async def send_and_wait(self, exec_op: str, data: dict, timeout: float = 10.0) -> dict: - """ - 发送请求并等待响应 - :param exec_op: 操作类型 - :param data: 要发送的数据字典 - :param timeout: 超时时间(秒) - :return: 服务器返回的字典结果 - """ - callback_id = f"req_{self._request_counter}" - self._request_counter += 1 - - # 创建 Future 对象用于等待结果 - loop = asyncio.get_running_loop() - future = loop.create_future() - self._pending_requests[callback_id] = future + callback_id = f"req_{uuid.uuid4().hex[:12]}" + self._request_counter += 1 + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._pending_requests[callback_id] = future + + try: + packet = { + "op": 4, + "exec_op": exec_op, + "callback_id": callback_id + } + packet.update(data) + await self.send(json.dumps(packet)) + return await asyncio.wait_for(future, timeout) + finally: + self._pending_requests.pop(callback_id, None) - try: - # 构建请求包 - packet = { - "op": 4, - "exec_op": exec_op, - "callback_id": str(callback_id) - } - packet.update(data) # 合并自定义数据 - - # 发送请求 - await self.send(json.dumps(packet)) - - # 等待结果或超时 - return await asyncio.wait_for(future, timeout) - finally: - # 清理 pending 请求 - self._pending_requests.pop(callback_id, None) async def start(self): - async with self._conn_lock: - if self._active: - return - self._active = True - self._manual_stop = False - self._connection_task = asyncio.create_task(self._connection_manager()) + async with self._conn_lock: + if self._active or self._is_connecting: + return + self._active = True + self._manual_stop = False + self._is_connecting = True + self._connection_task = asyncio.create_task(self._connection_manager()) async def stop(self): - self._active = False - self._manual_stop = True - - if self._ws and self._ws.state is websockets.State.OPEN: - await self._ws.close(reason="MCDR插件端主动关闭连接") - self._ws = None - - if self._heartbeat_task and not self._heartbeat_task.done(): - self._heartbeat_task.cancel() - try: - await self._heartbeat_task - except asyncio.CancelledError: - pass - self._heartbeat_task = None - - if self._connection_task and not self._connection_task.done(): - self._connection_task.cancel() - try: - await self._connection_task - except asyncio.CancelledError: - pass - self._connection_task = None - - logger = ServerInterface.get_instance().logger - logger.info("WebSocket 客户端已停止") + self._active = False + self._manual_stop = True + + if self._ws and self._ws.state is websockets.State.OPEN: + await self._ws.close(reason="MCDR插件端主动关闭连接") + self._ws = None + + if self._heartbeat_task and not self._heartbeat_task.done(): + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass + self._heartbeat_task = None + + if self._connection_task and not self._connection_task.done(): + self._connection_task.cancel() + try: + await self._connection_task + except asyncio.CancelledError: + pass + self._connection_task = None + + self._is_connecting = False + try: + ServerInterface.get_instance().logger.info("WebSocket 客户端已停止") + except Exception: + pass + async def _start_heartbeat(self, interval_seconds: int): - """启动心跳循环""" - if self._heartbeat_task and not self._heartbeat_task.done(): - self._heartbeat_task.cancel() - try: - await self._heartbeat_task - except asyncio.CancelledError: - pass + if self._heartbeat_task and not self._heartbeat_task.done(): + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass - async def heartbeat_loop(): - try: - while True: - await asyncio.sleep(interval_seconds - 10) # 减少十秒 因为给的是到期时间,需要在到期之前发送心跳 - if not (self._active and self._ws and self._ws.state is websockets.State.OPEN): - break - await self.send(json.dumps({"op": 2})) - except (ConnectionClosed, asyncio.CancelledError): - pass - - self._heartbeat_task = asyncio.create_task(heartbeat_loop()) - async def _connection_manager(self): - """连接生命周期管理器 - 使用指数退避算法""" - while self._active: + async def heartbeat_loop(): try: - # 检查是否超过最大重连次数 - if self._reconnect_attempts > 0 and self._reconnect_attempts >= self._max_reconnect_attempts: + while True: + await asyncio.sleep(max(10, interval_seconds - 10)) + if not (self._active and self._ws and self._ws.state is websockets.State.OPEN): + break + await self.send(json.dumps({"op": 2})) + except (ConnectionClosed, asyncio.CancelledError): + pass + + self._heartbeat_task = asyncio.create_task(heartbeat_loop()) + + async def _connection_manager(self): + try: + while self._active: + if self._reconnect_attempts >= self._max_reconnect_attempts: try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] 已达到最大重连次数({self._max_reconnect_attempts}次),停止重连") - except: + ServerInterface.get_instance().logger.warning( + f"[EasyBot] 已达到最大重连次数({self._max_reconnect_attempts}次),停止重连") + except Exception: pass self._active = False break - - # 指数退避计算,带随机抖动避免雪崩 - jitter = 0.1 * (1 - 2 * (self._reconnect_attempts % 2)) # ±10%的抖动 - delay = min(self._reconnect_base * (2 ** self._reconnect_attempts), - self._max_reconnect_interval) * (1 + jitter) - + if self._reconnect_attempts > 0: try: - server = ServerInterface.get_instance() - server.logger.info(f"[EasyBot] {delay:.1f}秒后尝试重连 (第{self._reconnect_attempts}次/共{self._max_reconnect_attempts}次)") - self._last_error_log_time = time.time() - except: + ServerInterface.get_instance().logger.info( + f"[EasyBot] {self._reconnect_delay}秒后尝试重连 " + f"(第{self._reconnect_attempts}次/共{self._max_reconnect_attempts}次)") + except Exception: pass - await asyncio.sleep(delay) - - async with websockets.connect(self.ws_url) as websocket: - self._ws = websocket - self._reconnect_attempts = 0 # 重置重连计数器 - self._last_error_log_time = 0 # 重置日志时间 - try: + await asyncio.sleep(self._reconnect_delay) + + try: + async with websockets.connect(self.ws_url) as websocket: + self._ws = websocket + self._reconnect_attempts = 0 await self.on_open() await self._message_pump() - except Exception as inner_error: - # 连接过程中的错误,不增加重连计数,每次都记录日志 - try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] 连接中错误: {type(inner_error).__name__}") - # 即使记录日志也更新时间戳,保持一致性 - self._last_error_log_time = time.time() - except: - pass - - except (ConnectionRefusedError, ConnectionClosedError): - self._reconnect_attempts += 1 - # 连接错误,每次都记录日志 - try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] 连接失败 (第{self._reconnect_attempts}次/共{self._max_reconnect_attempts}次)") - self._last_error_log_time = time.time() - except: - pass - except Exception as e: - # 非连接错误,每次都记录日志 - try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] 发生错误: {type(e).__name__}") - self._last_error_log_time = time.time() - except: - pass - await asyncio.sleep(1) - - await self._cleanup_connection() + except (ConnectionRefusedError, ConnectionClosedError): + self._reconnect_attempts += 1 + try: + ServerInterface.get_instance().logger.warning( + f"[EasyBot] 连接失败 (第{self._reconnect_attempts}次/共{self._max_reconnect_attempts}次)") + except Exception: + pass + except Exception as e: + self._reconnect_attempts += 1 + try: + ServerInterface.get_instance().logger.warning( + f"[EasyBot] 连接异常: {type(e).__name__}") + except Exception: + pass + finally: + self._is_connecting = False + await self._cleanup_connection() async def _message_pump(self): - """消息泵循环""" try: while self._active and self._ws.state is websockets.State.OPEN: try: - message = await asyncio.wait_for( - self._ws.recv(), - timeout=1.0 # 添加超时以定期检查连接状态 - ) + message = await asyncio.wait_for(self._ws.recv(), timeout=1.0) await self.on_message(message) except asyncio.TimeoutError: - continue # 正常轮询检查 + continue except ConnectionClosed as e: await self.on_close(e.code, e.reason) async def _cleanup_connection(self): - """清理连接资源""" if self._ws and self._ws.state is websockets.State.OPEN: await self._ws.close(reason="MCDR端清理连接资源主动关闭") self._ws = None async def send(self, message): - """安全消息发送方法""" if not (self._active and self._ws and self._ws.state is websockets.State.OPEN): raise ConnectionError("当前WebSocket客户端不在线,插件可能还未连接到EasyBot服务!") - + if get_config()["debug"]: try: - server = ServerInterface.get_instance() - server.logger.info(f"[EasyBot] 发送: {message}") - except: + ServerInterface.get_instance().logger.info(f"[EasyBot] 发送: {message}") + except Exception: pass await self._ws.send(message) - # 需要实现的生命周期回调 async def on_open(self): try: - server = ServerInterface.get_instance() - server.logger.info("[EasyBot] 已与主程序建立连接") - except: + ServerInterface.get_instance().logger.info("[EasyBot] 已与主程序建立连接") + except Exception: pass async def on_message(self, message): @@ -293,22 +258,22 @@ async def on_message(self, message): op = data["op"] if op == 0: self._session_info = SessionInfo.from_dict(data) - info: SessionInfo = self._session_info - server.logger.info(f"[EasyBot] 目标核心版本: {info.get_version()}-{info.get_system()} [{info.get_dotnet()}] 心跳{info.get_interval()}s 会话ID: {info.get_session_id()}") - server.logger.info(f"[EasyBot] 准备发送鉴权") + info = self._session_info + server.logger.info( + f"[EasyBot] 目标核心版本: {info.get_version()}-{info.get_system()} " + f"[{info.get_dotnet()}] 心跳{info.get_interval()}s " + f"会话ID: {info.get_session_id()}") + server.logger.info("[EasyBot] 准备发送鉴权") await self.send(json.dumps({ "op": 1, "token": get_config()["token"], "plugin_version": get_plugin_version(), - "server_description": f"MCDR_{ServerInterface.get_instance().get_server_information().version}", + "server_description": f"MCDR_{server.get_server_information().version}", })) elif op == 3: self._session_info.set_server_name(data["server_name"]) server.logger.info(f"[EasyBot] 身份验证成功... [{data['server_name']}]") - - # [新增] 连接成功后,立即请求同步配置 await self.start_update_sync_settings() - if self._session_info is not None: interval = self._session_info.get_interval() await self._start_heartbeat(interval) @@ -318,7 +283,6 @@ async def on_message(self, message): ctx = ExecContext(data["callback_id"], data["exec_op"], self) for handler in self._listeners[exec_op]: try: - # 自动处理同步/异步函数 if asyncio.iscoroutinefunction(handler): await handler(ctx, data, self._session_info) else: @@ -326,7 +290,6 @@ async def on_message(self, message): except Exception as e: server.logger.error(f"[EasyBot] 处理 exec_op={exec_op} 时出错: {str(e)}") else: - # 未知 exec_op,若存在回调 ID,则回传失败避免调用方超时 callback_id = data.get("callback_id") if callback_id: try: @@ -347,27 +310,23 @@ async def on_message(self, message): future.set_result(data) except Exception as e: try: - server = ServerInterface.get_instance() - server.logger.error(f"[EasyBot] 处理消息时出错: {str(e)}") - except: + ServerInterface.get_instance().logger.error(f"[EasyBot] 处理消息时出错: {str(e)}") + except Exception: pass async def on_close(self, code, reason): try: - server = ServerInterface.get_instance() - server.logger.info(f"[EasyBot] 连接关闭: {code} {reason}") - except: + ServerInterface.get_instance().logger.info(f"[EasyBot] 连接关闭: {code} {reason}") + except Exception: pass async def on_error(self, error): try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] WebSocket错误: {error}") - except: - # 静默失败,避免日志系统本身的错误 + ServerInterface.get_instance().logger.warning(f"[EasyBot] WebSocket错误: {error}") + except Exception: pass - async def _send_packet(self, exec_op:str, data:dict): + async def _send_packet(self, exec_op: str, data: dict): if self._active: packet = { "op": 4, @@ -389,32 +348,37 @@ async def report_player(self, player_name: str): info = build_player_info(player_name) if info is None: try: - server = ServerInterface.get_instance() - server.logger.warning(f"[EasyBot] 无法获取 {player_name} 的玩家信息,跳过报告") - except: + ServerInterface.get_instance().logger.warning( + f"[EasyBot] 无法获取 {player_name} 的玩家信息,跳过报告") + except Exception: pass return None await self._send_packet("REPORT_PLAYER", { "player_name": player_name, "player_uuid": info["player_uuid"], - "player_ip": info["ip"], # 注意这里的键是 "ip",而不是 "player_ip" + "player_ip": info["ip"], }) return info - async def push_message(self, player_name: str, message: str, use_command:bool): + async def push_message(self, player_name: str, message: str, use_command: bool, extra: list = None): from easybot_mcdr.api.player import build_player_info info = build_player_info(player_name) if info is None: - logger = ServerInterface.get_instance().logger - logger.warning(f"无法获取 {player_name} 的玩家信息,跳过消息上报") + try: + ServerInterface.get_instance().logger.warning( + f"无法获取 {player_name} 的玩家信息,跳过消息上报") + except Exception: + pass return - info['player_name_raw'] = player_name - await self._send_packet("SYNC_MESSAGE", { + packet = { "player": info, "message": message, "use_command": use_command - }) + } + if extra: + packet["extra"] = extra + await self._send_packet("SYNC_MESSAGE", packet) async def push_death(self, player_name: str, killer: str, message: str): from easybot_mcdr.api.player import build_player_info @@ -442,20 +406,18 @@ async def push_exit(self, player_name: str): await self._send_packet("SYNC_ENTER_EXIT_MESSAGE", { "player": info, "is_enter": False - }) - + }) + async def get_social_account(self, player_name: str): - resp = await self.send_and_wait("GET_SOCIAL_ACCOUNT", { + return await self.send_and_wait("GET_SOCIAL_ACCOUNT", { "player_name": player_name }) - return resp - + async def start_bind(self, player_name: str): - resp = await self.send_and_wait("START_BIND", { + return await self.send_and_wait("START_BIND", { "player_name": player_name }) - return resp - + async def push_cross_server_message(self, player: str, message: str): config = get_config() server_name = config["server_name"] @@ -466,19 +428,15 @@ async def push_cross_server_message(self, player: str, message: str): }) async def start_update_sync_settings(self): - """请求服务端同步配置""" await self._send_packet("NEED_SYNC_SETTING", {}) async def server_state(self, players_str: str): - """上报服务器状态""" await self._send_packet("SERVER_STATE_CHANGED", { "token": get_config()["token"], "players": players_str }) async def data_record(self, record_type: str, data: str, name: str): - """数据埋点上报""" - # record_type 对应 Java 枚举: Online, Offline, Chat, Kill, Command 等 await self._send_packet("DATA_RECORD", { "type": record_type, "data": data, @@ -487,11 +445,9 @@ async def data_record(self, record_type: str, data: str, name: str): }) async def get_new_version(self): - """获取新版本信息""" return await self.send_and_wait("GET_NEW_VERSION", {}) async def get_bind_info(self, player_name: str): - """获取绑定详情""" return await self.send_and_wait("GET_BIND_INFO", { "player_name": player_name - }) \ No newline at end of file + }) diff --git a/lang/en_us.json b/lang/en_us.json new file mode 100644 index 0000000..41551f8 --- /dev/null +++ b/lang/en_us.json @@ -0,0 +1,20 @@ +{ + "easybot_bridge.connected": "Connected to EasyBot server: {url}", + "easybot_bridge.disconnected": "Disconnected: {reason}", + "easybot_bridge.online": "Identity verified! Server name: {name}", + "easybot_bridge.heartbeat_stopped": "Heartbeat stopped", + "easybot_bridge.reconnecting": "Attempting to reconnect to server...", + "easybot_bridge.send_failed": "Failed to send message: {error}", + "easybot_bridge.session_unavailable": "Session unavailable, message discarded", + "easybot_bridge.unknown_opcode": "Received unknown OpCode: {opcode}", + "easybot_bridge.unknown_operation": "Received unknown operation: {operation}", + "easybot_bridge.rpc_call_failed": "RPC call failed: {error}", + "easybot_bridge.config_loaded": "Configuration loaded", + "easybot_bridge.starting": "EasyBot Bridge starting...", + "easybot_bridge.stopping": "EasyBot Bridge stopping...", + "easybot_bridge.stopped": "EasyBot Bridge stopped", + "easybot_bridge.received_hello": "Received server Hello handshake", + "easybot_bridge.identity_sent": "Identity sent", + "easybot_bridge.identity_verified": "Identity verified! Server name: {name}", + "easybot_bridge.sync_settings_received": "Sync settings received" +} diff --git a/lang/zh_cn.json b/lang/zh_cn.json new file mode 100644 index 0000000..2b586cd --- /dev/null +++ b/lang/zh_cn.json @@ -0,0 +1,20 @@ +{ + "easybot_bridge.connected": "已连接到 EasyBot 服务器: {url}", + "easybot_bridge.disconnected": "已断开连接: {reason}", + "easybot_bridge.online": "身份验证成功! 服务器名: {name}", + "easybot_bridge.heartbeat_stopped": "心跳已停止", + "easybot_bridge.reconnecting": "正在尝试重连服务器...", + "easybot_bridge.send_failed": "发送消息失败: {error}", + "easybot_bridge.session_unavailable": "尝试发送消息但 session 不可用,消息被丢弃", + "easybot_bridge.unknown_opcode": "收到未知 OpCode: {opcode}", + "easybot_bridge.unknown_operation": "收到未知操作: {operation}", + "easybot_bridge.rpc_call_failed": "调用RPC方法失败: {error}", + "easybot_bridge.config_loaded": "配置已加载", + "easybot_bridge.starting": "EasyBot Bridge 正在启动...", + "easybot_bridge.stopping": "EasyBot Bridge 正在关闭...", + "easybot_bridge.stopped": "EasyBot Bridge 已关闭", + "easybot_bridge.received_hello": "收到服务器 Hello 握手", + "easybot_bridge.identity_sent": "身份已上报", + "easybot_bridge.identity_verified": "身份验证成功! 服务器名: {name}", + "easybot_bridge.sync_settings_received": "同步设置已收到" +} diff --git a/readme.md b/readme.md index 3b6cdc3..ef26ba1 100644 --- a/readme.md +++ b/readme.md @@ -1,51 +1,187 @@ -# EasyBot 机器人插件 +# EasyBot-MCDR -> 一款支持全方位功能于一体的服务器管理工具,全方位优化游戏社区体验! -> 目前功能包括消息同步、自定义命令、绑定管理、高级权限控制、群组互动、自定义模板支持以及自定义插件支持等 +> 一款集消息同步、绑定管理、群组互动等功能于一体的 MCDR 服务器管理插件,全方位优化游戏社区体验。 + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![MCDR](https://img.shields.io/badge/MCDR-%3E%3D2.14-green.svg)](https://github.com/Fallen-Breath/MCDReforged) +[![Python](https://img.shields.io/badge/Python-3.12+-yellow.svg)](https://www.python.org/) -## 文档 +## 简介 -[点我查看](https://docs.inectar.cn/docs/easybot/quick_start/plugin/mcdr/install_mcdr) +EasyBot-MCDR 通过 WebSocket 将 Minecraft 服务器与 EasyBot 中心服务器连接,实现游戏内消息与外部社交平台(如 QQ 群、管理面板)的双向同步。 -## 开发环境 +**服务端 → 平台**: 转发游戏内聊天、玩家进出、死亡事件、服务器状态 +**平台 → 服务端**: 接收指令发送消息、踢人、执行命令、管理绑定、跨服通信 -- `Python`: `3.12.8` -- `MCDR`: `2.14.5` +## 功能特性 -## 适用服务器 +### 消息同步 +- 游戏内聊天实时同步至社交平台,反之亦然 +- 支持富文本渲染:文本、图片、@提及、表情、文件、回复 +- 聊天图片支持:安装 [ChatImage](https://github.com/kitUIN/ChatImage) 客户端模组后,群聊图片可在游戏内直接预览 +- 跨服消息转发(`!!say` / `!!esay`) + +### 绑定系统 +- 玩家通过 `!!bind` 命令将 Minecraft 账号与社交平台账号绑定 +- 支持绑定/解绑时自动执行命令 +- 支持绑定时自动加入白名单、解绑时自动移除并踢出 + +### 玩家事件 +- 进入/退出/死亡通知同步至平台 +- 机器人(假玩家)名称前缀过滤 +- @提及时触发命令与音效 + +### 远程管理 +- 平台远程执行服务器命令(通过 RCON) +- 远程踢出玩家 +- PlaceholderAPI 变量解析(部分支持) + +### 服务器适配 +- 自动兼容 Forge / Fabric / Spigot / Paper / Vanilla 服务端日志格式 +- 自动配置 RCON(检测、生成密码、写入配置) +- 在线/离线模式均支持,自动生成离线 UUID + +### 其他 +- 配置热重载(`!!ez reload`) +- 内置本地图片服务器,支持局域网内图片访问 +- 可选 imgbb 图床上传,支持公网图片分享 + +## 环境要求 + +| 依赖 | 版本 | +| :--- | :--- | +| Python | 3.12+ | +| MCDReforged | >= 2.14 | +| Minecraft 服务端 | 原版 / CraftBukkit / Spigot / Paper / Fabric / Forge | -> 得益于 MCDR 的工作机制, 你可以在绝大部分服务器上使用 EasyBot。 +## 安装 -## 兼容性 +1. 安装 [MCDReforged](https://github.com/Fallen-Breath/MCDReforged) +2. 下载本插件,放入 MCDR 的 `plugins/` 目录 +3. 安装 Python 依赖: + ```bash + pip install -r requirements.txt + ``` +4. 重启 MCDR + +## 快速开始 + +1. 启动 MCDR 后,编辑 `config/easybot_mcdr/config.json`: + ```json + { + "token": "你的认证 Token", + "ws": "ws://你的EasyBot服务器:26990/bridge", + "server_name": "我的服务器" + } + ``` +2. 在游戏内使用 `!!bind` 开始绑定流程 +3. 在社交平台输入绑定码完成绑定 + +详细文档请查看:[在线文档](https://docs.inectar.cn/docs/easybot/quick_start/plugin/mcdr/install_mcdr) + +## 命令 + +| 命令 | 说明 | 权限 | +| :--- | :--- | :--- | +| `!!bind` / `!!ez bind` | 绑定社交平台账号 | 所有玩家 | +| `!!unbind` / `!!ez unbind` | 解绑账号 | 所有玩家 | +| `!!say <消息>` / `!!esay <消息>` | 跨服发送消息 | 所有玩家 | +| `!!ez reload` | 重载配置 | OP | -`EasyBot_MCDR`是`EasyBot`插件的一个分支,由于实现原理不同,他的特性与功能与`EasyBot-Bukkit`有所不同。 +## 配置说明 + +配置文件路径:`config/easybot_mcdr/config.json` + +### 基础配置 + +| 字段 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `token` | string | `""` | EasyBot 中心服务器认证 Token | +| `ws` | string | `"ws://localhost:26990/bridge"` | WebSocket 服务地址 | +| `server_name` | string | `"server_name"` | 服务器标识名 | +| `debug` | bool | `false` | 开启调试日志 | +| `kick_delay_seconds` | int | `5` | 未绑定玩家踢出延迟(秒) | +| `handler.enabled` | bool | `true` | 启用自定义日志处理器 | +| `enable_white_list` | bool | `true` | 启用白名单联动 | + +### 消息同步 + +| 字段 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `message_sync.ignore_mcdr_command` | bool | `true` | 同步时忽略 `!!` 命令 | + +### 绑定事件 + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| `events.bind_success.exec_command` | bool | 绑定成功时执行命令 | +| `events.bind_success.add_whitelist` | bool | 绑定成功时加入白名单 | +| `events.bind_success.comamnds` | list | 绑定成功执行的命令列表(支持 `#player` `#name` `#account`) | +| `events.un_bind.kick` | bool | 解绑时踢出玩家 | +| `events.un_bind.remove_white_list` | bool | 解绑时移除白名单 | +| `events.un_bind.exec_command` | bool | 解绑时执行命令 | +| `events.un_bind.comamnds` | list | 解绑时执行的命令列表 | + +### @提及事件 + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| `events.message.on_at.exec_command` | bool | 被@时执行命令 | +| `events.message.on_at.comamnds` | list | 执行的命令列表(支持 `#player`) | +| `events.message.on_at.sound.play_sound` | bool | 被@时播放音效 | +| `events.message.on_at.sound.run` | string | 音效命令(支持 `#player`) | +| `events.message.on_at.sound.count` | int | 播放次数 | +| `events.message.on_at.sound.interval_ms` | int | 播放间隔(毫秒) | + +### 机器人过滤 + +| 字段 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `bot_filter.enabled` | bool | `true` | 启用假玩家过滤 | +| `bot_filter.prefixes` | list | `["Bot_","BOT_","bot_"]` | 识别为机器人的名称前缀 | + +### 图片上传(可选) + +启用后,QQ 群中的图片将在游戏内通过 [ChatImage](https://github.com/kitUIN/ChatImage) 模组渲染。 + +| 字段 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `image_upload.enabled` | bool | `false` | 启用图片上传 | +| `image_upload.imgbb_api_key` | string | `""` | [imgbb](https://api.imgbb.com/) API Key(免费注册获取) | + +> 未配置 imgbb API Key 时,将自动启动本地 HTTP 图片服务器(仅限局域网访问)。 + +## 兼容性 +`EasyBot-MCDR` 是 `EasyBot` 插件的 MCDR 分支,与 `EasyBot-Bukkit` 功能有部分差异: -| 特性 | 原因 | -| :-------------- | ---------------------------------------------- | -| 死亡同步 | ❌ 不同服务器实现原理不同,无法稳定判断死亡原因 | -| PlaceholderAPI | ⚠ 不完全支持: 目前只支持 `%player_name%` | +| 特性 | MCDR 支持 | 说明 | +| :--- |:-------:| :--- | +| 消息同步 | ✅ | | +| 进入退出通知 | ✅ | | +| 强制绑定 | ✅ | | +| 命令绑定账号 | ✅ | | +| 命令模式消息同步 | ✅ | | +| 热重载 | ✅ | | +| 执行命令 | ✅ | | +| 绑定时执行命令 | ✅ | | +| 联动原版白名单 | ✅ | | +| 解绑时执行命令 | ✅ | | +| 死亡同步 | x | 不同服务端实现原理不同,无法稳定判断死亡原因 | +| PlaceholderAPI | ⚠️ | 目前仅支持 `%player_name%`,完整支持计划中 | -| 特性 | MCDR 解决方案 | -| :--------------- | ------------- | -| 消息同步 | ✅ 支持 | -| 进入退出通知 | ✅ 支持 | -| 强制绑定 | ✅ 支持 | -| 使用命令绑定账号 | ✅ 支持 | -| 命令模式消息同步 | ✅ 支持 | -| 热重载 | ✅ 支持 | -| 执行命令 | ✅ 支持 | -| 绑定时执行命令 | ✅ 支持 | -| 联动原版白名单 | ✅ 支持 | -| 解绑时执行命令 | ✅ 支持 | +## 开发 -## PlaceholderAPI? +``` +Python: 3.12.8 +MCDR: 2.14.5 +``` -PlaceholderAPI 是一个用于 `Bukkit/Spigot` 服务器端的 API,它提供了许多占位符。 -在MCDR中`EasyBot`只实现了`%player_name%` +## 许可证 -### 未来计划 +[GPL-3.0](LICENSE) -> ⚠ 此功能正在开发 +## 作者 -检测到服务器安装了PlaceholderAPI 时, 使用RCON执行解析变量。 +- [LBY123165](https://github.com/LBY123165) +- [MiuxuE](https://github.com/MiuxuE) diff --git a/requirements.txt b/requirements.txt index 6469a75..2852273 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ websockets>=14.2 -requests~=2.32.3 \ No newline at end of file +requests>=2.32.3 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b415b49 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import pytest +import asyncio +import json +import websockets +from queue import Queue +from contextlib import asynccontextmanager + + +class MockEasyBotServer: + """模拟 EasyBot WebSocket 服务端""" + + def __init__(self, host="localhost", port=0): + self.host = host + self.port = port + self.server = None + self.clients = [] + self.messages = Queue() + self.token = "test_token_12345" + self.server_name = "test_server" + + async def handler(self, websocket): + self.clients.append(websocket) + try: + # 发送会话信息 + session_info = { + "op": 0, + "version": "MockBridgeCore", + "system": "Test", + "dotnet": "test", + "session_id": "test-session-id", + "token": self.token, + "interval": 30 + } + await websocket.send(json.dumps(session_info)) + + # 处理消息 + async for message in websocket: + data = json.loads(message) + self.messages.put(data) + + # 处理鉴权 + if data.get("op") == 1: + if data.get("token") == self.token: + await websocket.send(json.dumps({ + "op": 3, + "server_name": self.server_name + })) + else: + await websocket.send(json.dumps({ + "op": 3, + "server_name": "auth_failed" + })) + finally: + self.clients.remove(websocket) + + async def start(self): + self.server = await websockets.serve(self.handler, self.host, self.port) + actual_port = self.server.sockets[1].getsockname()[1] + self.port = actual_port + return actual_port + + async def stop(self): + if self.server: + self.server.close() + await self.server.wait_closed() + + def get_port(self): + return self.port + + +@pytest.fixture +async def mock_server(): + """提供 mock 服务器""" + server = MockEasyBotServer() + await server.start() + yield server + await server.stop() + + +@pytest.fixture +def ws_url(mock_server): + """提供 WebSocket URL""" + return f"ws://localhost:{mock_server.get_port()}" diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..13cc6b8 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,147 @@ +import pytest +import asyncio +import json +import websockets +from unittest.mock import patch, MagicMock + + +class SimpleWsClient: + """简化的 WebSocket 客户端,用于测试""" + + def __init__(self, url): + self.ws_url = url + self._ws = None + self._active = False + self.messages_received = [] + + async def connect(self): + self._active = True + self._ws = await websockets.connect(self.ws_url) + + async def send(self, message): + if self._ws: + await self._ws.send(message) + + async def recv(self): + if self._ws: + return await self._ws.recv() + + async def close(self): + self._active = False + if self._ws: + await self._ws.close() + + +@pytest.mark.asyncio +async def test_mock_server_starts(mock_server): + """测试 mock 服务器能正常启动""" + assert mock_server.get_port() > 0 + + +@pytest.mark.asyncio +async def test_websocket_connection(ws_url, mock_server): + """测试 WebSocket 连接""" + client = SimpleWsClient(ws_url) + await client.connect() + + # 验证连接 + assert client._ws is not None + assert client._ws.state is websockets.State.OPEN + + await client.close() + + +@pytest.mark.asyncio +async def test_session_info_received(ws_url, mock_server): + """测试收到会话信息""" + client = SimpleWsClient(ws_url) + await client.connect() + + # 接收会话信息 + msg = await client.recv() + data = json.loads(msg) + + assert data["op"] == 0 + assert data["version"] == "MockBridgeCore" + assert "session_id" in data + + await client.close() + + +@pytest.mark.asyncio +async def test_auth_success(ws_url, mock_server): + """测试鉴权成功""" + client = SimpleWsClient(ws_url) + await client.connect() + + # 接收会话信息 + await client.recv() + + # 发送鉴权 + auth_packet = { + "op": 1, + "token": "test_token_12345", + "plugin_version": "test", + "server_description": "MCDR_Test" + } + await client.send(json.dumps(auth_packet)) + + # 接收鉴权响应 + msg = await client.recv() + data = json.loads(msg) + + assert data["op"] == 3 + assert data["server_name"] == "test_server" + + await client.close() + + +@pytest.mark.asyncio +async def test_auth_failed(ws_url, mock_server): + """测试鉴权失败""" + client = SimpleWsClient(ws_url) + await client.connect() + + # 接收会话信息 + await client.recv() + + # 发送错误的 token + auth_packet = { + "op": 1, + "token": "wrong_token", + "plugin_version": "test", + "server_description": "MCDR_Test" + } + await client.send(json.dumps(auth_packet)) + + # 接收鉴权响应 + msg = await client.recv() + data = json.loads(msg) + + assert data["op"] == 3 + assert data["server_name"] == "auth_failed" + + await client.close() + + +@pytest.mark.asyncio +async def test_message_received_by_server(ws_url, mock_server): + """测试服务端收到消息""" + client = SimpleWsClient(ws_url) + await client.connect() + + # 接收会话信息 + await client.recv() + + # 发送心跳 + await client.send(json.dumps({"op": 2})) + + # 等待消息处理 + await asyncio.sleep(0.1) + + # 验证服务端收到消息 + assert not mock_server.messages.empty() + received = mock_server.messages.get_nowait() + assert received["op"] == 2 + + await client.close() From d2c8b047ec545ec84a1dd71923394fcc251835da Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 21:46:50 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DCI=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc114bf..9c190ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,7 +159,8 @@ jobs: pip install mcdreforged>=2.14 - name: 检查模块导入 - run: python -c " + run: | + python -c " import importlib, sys, pathlib errors = [] From 67a77c13443477a2ad056adc5f039033937614c4 Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 22:00:18 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix=20:=E4=BF=AE=E5=A4=8Daction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 36 +---- .gitignore | 4 +- pytest.ini | 6 + tests/test_plugin.py | 305 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 315 insertions(+), 36 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/test_plugin.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c190ea..41c8c0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: run: | pip install -r requirements.txt pip install mcdreforged>=2.14 - pip install pytest pytest-asyncio + pip install pytest pytest-asyncio>=1.0.0 - name: 运行测试 run: pytest -v @@ -108,40 +108,6 @@ jobs: echo '```' >> $GITHUB_STEP_SUMMARY fi - build: - name: 构建 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - - name: 安装依赖 - run: | - pip install -r requirements.txt - pip install mcdreforged>=2.14 - - - name: 构建插件包 - run: mcdreforged pack - - - name: 验证构建产物 - run: | - MCDR_FILE=$(find . -name "*.mcdr" | head -n 1) - if [ -z "$MCDR_FILE" ]; then - echo "错误:未找到 .mcdr 文件" - exit 1 - fi - echo "构建成功: $MCDR_FILE" - ls -lh "$MCDR_FILE" - - - uses: actions/upload-artifact@v4 - with: - name: easybot-mcdr-build - path: "*.mcdr" - import-check: name: 导入检查 runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 80f79d8..f585499 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__/ *.pyd *.pyi __temp -.idea \ No newline at end of file +.idea +.pytest_cache/ +*.egg-info/ \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..dc3a138 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_functions = test_* +asyncio_default_fixture_loop_scope = function diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..2fbe15c --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,305 @@ +""" +EasyBot-MCDR 插件测试套件 +""" +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +# 确保项目根目录在 sys.path 中 +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +# ── 1. 模块导入测试 ───────────────────────────────────────────────── +def test_import_config(): + from easybot_mcdr.config import get_config, load_config, save_config + assert callable(get_config) + + +def test_import_meta(): + from easybot_mcdr.meta import get_plugin_version + assert callable(get_plugin_version) + + +def test_import_message(): + from easybot_mcdr.message import ( + Segment, SegmentType, TextSegment, ImageSegment, + FileSegment, AtSegment, ReplySegment, UnknownSegment, + segments_from_list, segments_to_list, + ) + + +def test_import_rpc(): + from easybot_mcdr.rpc import bridge_rpc, bind_registered_handlers, _registry + assert callable(bridge_rpc) + assert isinstance(_registry, dict) + + +def test_import_event_bus(): + from easybot_mcdr.event_bus import EventBus + eb = EventBus() + assert hasattr(eb, "on") + assert hasattr(eb, "emit") + + +def test_import_prefix_handler(): + from easybot_mcdr.impl.prefix_handler import PrefixNameHandler + assert callable(PrefixNameHandler) + + +def test_import_ws_client(): + from easybot_mcdr.websocket.ws import EasyBotWsClient, SessionInfo + assert hasattr(EasyBotWsClient, "listen_exec_op") + assert hasattr(SessionInfo, "from_dict") + + +def test_import_player_api(): + from easybot_mcdr.api.player import ( + PlayerInfo, generate_offline_uuid, update_player_uuid, + get_data_map, check_cache, check_online, get_player_list, + ) + assert callable(generate_offline_uuid) + + +def test_import_impl_modules(): + """验证所有 impl 子模块均可导入。""" + import easybot_mcdr.impl.rpc_handlers + import easybot_mcdr.impl.un_bind_notify + import easybot_mcdr.impl.message_sync + import easybot_mcdr.impl.exec_command + import easybot_mcdr.impl.cross_server_chat + import easybot_mcdr.impl.player_list + import easybot_mcdr.impl.papi + import easybot_mcdr.impl.get_server_info + import easybot_mcdr.impl.bridge_behavior_impl + + +# ── 2. 配置加载测试 ───────────────────────────────────────────────── +def test_config_json_parse(): + config_path = PROJECT_ROOT / "data" / "config.json" + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + assert isinstance(cfg, dict) + assert "token" in cfg + assert "ws" in cfg + + +def test_config_required_fields(): + config_path = PROJECT_ROOT / "data" / "config.json" + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + required = ["token", "ws", "server_name", "debug", "kick_delay_seconds", + "message_sync", "message", "enable_white_list", "events", + "bot_filter"] + for key in required: + assert key in cfg, f"缺少必填配置项: {key}" + + +def test_config_bot_filter(): + config_path = PROJECT_ROOT / "data" / "config.json" + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + bf = cfg["bot_filter"] + assert "enabled" in bf + assert "prefixes" in bf + assert isinstance(bf["prefixes"], list) + assert len(bf["prefixes"]) > 0 + + +# ── 3. 消息模型测试 ───────────────────────────────────────────────── +def test_text_segment(): + from easybot_mcdr.message import TextSegment, SegmentType, Segment + seg = TextSegment("hello") + assert seg.type == SegmentType.TEXT + assert seg.text == "hello" + d = seg.to_dict() + assert d == {"type": 2, "text": "hello"} + restored = Segment.from_dict(d) + assert isinstance(restored, TextSegment) + assert restored.text == "hello" + + +def test_image_segment(): + from easybot_mcdr.message import ImageSegment, SegmentType, Segment + seg = ImageSegment("https://example.com/img.png") + assert seg.type == SegmentType.IMAGE + d = seg.to_dict() + restored = Segment.from_dict(d) + assert isinstance(restored, ImageSegment) + assert restored.url == "https://example.com/img.png" + + +def test_file_segment(): + from easybot_mcdr.message import FileSegment, SegmentType, Segment + seg = FileSegment("https://example.com/file.pdf", name="doc.pdf") + d = seg.to_dict() + restored = Segment.from_dict(d) + assert isinstance(restored, FileSegment) + assert restored.file_url == "https://example.com/file.pdf" + assert restored.name == "doc.pdf" + + +def test_at_segment(): + from easybot_mcdr.message import AtSegment, SegmentType, Segment + seg = AtSegment("Steve") + d = seg.to_dict() + restored = Segment.from_dict(d) + assert isinstance(restored, AtSegment) + assert restored.at_user_name == "Steve" + + +def test_reply_segment(): + from easybot_mcdr.message import ReplySegment, SegmentType, Segment + seg = ReplySegment("msg_123", text="original") + d = seg.to_dict() + restored = Segment.from_dict(d) + assert isinstance(restored, ReplySegment) + assert restored.message_id == "msg_123" + assert restored.text == "original" + + +def test_segments_roundtrip(): + from easybot_mcdr.message import TextSegment, ImageSegment, AtSegment, segments_from_list, segments_to_list + original = [TextSegment("hi"), ImageSegment("url"), AtSegment("Bob")] + data = segments_to_list(original) + restored = segments_from_list(data) + assert len(restored) == 3 + assert restored[0].text == "hi" + assert restored[1].url == "url" + assert restored[2].at_user_name == "Bob" + + +# ── 4. 玩家 API 测试 ──────────────────────────────────────────────── +def test_player_info_construction(): + from easybot_mcdr.api.player import PlayerInfo + p = PlayerInfo("127.0.0.1", "Steve", "uuid-1234") + assert p.ip == "127.0.0.1" + assert p.name == "Steve" + assert p.uuid == "uuid-1234" + + +def test_offline_uuid_generation(): + from easybot_mcdr.api.player import generate_offline_uuid + uuid = generate_offline_uuid("Steve") + assert isinstance(uuid, str) + assert len(uuid) == 36 + assert uuid.count("-") == 4 + + +def test_offline_uuid_deterministic(): + from easybot_mcdr.api.player import generate_offline_uuid + uuid1 = generate_offline_uuid("TestPlayer") + uuid2 = generate_offline_uuid("TestPlayer") + assert uuid1 == uuid2, "UUID 生成应具有确定性" + + +def test_offline_uuid_different_names(): + from easybot_mcdr.api.player import generate_offline_uuid + uuid1 = generate_offline_uuid("Player1") + uuid2 = generate_offline_uuid("Player2") + assert uuid1 != uuid2, "不同名称应生成不同 UUID" + + +def test_player_data_map(): + from easybot_mcdr.api.player import get_data_map + dm = get_data_map() + assert "online_players" in dm + assert "uuid_map" in dm + assert "cache" in dm + + +# ── 5. 假人过滤测试 ───────────────────────────────────────────────── +def test_bot_filter_matching(): + """测试假人前缀匹配逻辑。""" + prefixes = ["Bot_", "BOT_", "bot_"] + assert any("Bot_Steve".startswith(p) for p in prefixes) + assert any("BOT_Creeper".startswith(p) for p in prefixes) + assert any("bot_Zombie".startswith(p) for p in prefixes) + assert not any("Steve".startswith(p) for p in prefixes) + assert not any("Notch".startswith(p) for p in prefixes) + + +def test_bot_filter_disabled(): + """假人过滤禁用时,不应过滤任何玩家。""" + prefixes = ["Bot_", "BOT_", "bot_"] + enabled = False + + def is_bot(player): + if not enabled: + return False + return any(player.startswith(p) for p in prefixes) + + assert not is_bot("Bot_Steve") + assert not is_bot("Steve") + + +# ── 6. Prefix Handler 测试 ────────────────────────────────────────── +def test_prefix_handler_creation(): + from easybot_mcdr.impl.prefix_handler import PrefixNameHandler + handler = PrefixNameHandler() + assert handler.get_name() == "easybot_prefix_handler" + + +def test_prefix_handler_parse_chat(): + """测试 PrefixNameHandler 可识别的标准服务器输出格式。""" + from easybot_mcdr.impl.prefix_handler import PrefixNameHandler + handler = PrefixNameHandler() + info = handler.parse_server_stdout( + "[12:00:00] [Server thread/INFO]: Hello world" + ) + assert info is not None + assert info.player == "Steve" + assert "Hello world" in info.content + + +# ── 7. RPC 注册表测试 ─────────────────────────────────────────────── +def test_rpc_decorator_registration(): + import easybot_mcdr.impl.rpc_handlers + from easybot_mcdr.rpc import _registry + assert len(_registry) > 0, "未注册任何 RPC 处理器" + + +def test_rpc_expected_ops(): + import easybot_mcdr.impl.rpc_handlers + from easybot_mcdr.rpc import _registry + expected = {"PING", "RUN_COMMAND", "KICK_PLAYER", + "SYNC_CHAT", "GET_PLAYER_LIST", "SYNC_SEGMENTS"} + registered = set(_registry.keys()) + missing = expected - registered + assert not missing, f"缺少 RPC 处理器: {missing}" + + +# ── 8. EventBus 测试 ──────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_event_bus_register_and_emit(): + from easybot_mcdr.event_bus import EventBus + eb = EventBus() + called = [] + + @eb.on("test_event") + def handler(**kwargs): + called.append(kwargs.get("value")) + + await eb.emit("test_event", value=42) + assert called == [42] + + +@pytest.mark.asyncio +async def test_event_bus_priority(): + from easybot_mcdr.event_bus import EventBus + eb = EventBus() + order = [] + + @eb.on("prio", priority=10) + def high(**kw): + order.append("high") + + @eb.on("prio", priority=1) + def low(**kw): + order.append("low") + + await eb.emit("prio") + assert order == ["high", "low"], f"优先级顺序错误: {order}" From 2d9ebf4c0158232851d72967c7b5693cc0609ac7 Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 22:06:29 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=E6=9B=B4=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=9A=84action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-and-release.yml | 35 ++++++++++++++++++ .github/workflows/ci.yml | 47 ++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 0de66ad..ca576e8 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -36,6 +36,33 @@ jobs: - name: 安装MCDReforged run: pip install mcdreforged>=2.14 + - name: 安装测试依赖 + run: pip install pytest pytest-asyncio>=1.0.0 -q + + - name: 运行插件测试 + id: plugin_test + run: | + pytest tests/test_plugin.py -v --tb=short 2>&1 | tee /tmp/test-output.txt + echo "TEST_EXIT_CODE=$?" >> $GITHUB_ENV + continue-on-error: true + + - name: 生成测试报告 + if: always() + run: | + TOTAL=$(grep -c "PASSED\|FAILED\|ERROR" /tmp/test-output.txt || echo "0") + PASSED=$(grep -c "PASSED" /tmp/test-output.txt || echo "0") + FAILED=$(grep -c "FAILED" /tmp/test-output.txt || echo "0") + + echo "TEST_PASSED=$PASSED" >> $GITHUB_ENV + echo "TEST_FAILED=$FAILED" >> $GITHUB_ENV + echo "TEST_TOTAL=$TOTAL" >> $GITHUB_ENV + + if [ "$FAILED" -gt "0" ]; then + echo "TEST_STATUS=❌ $FAILED 项失败" >> $GITHUB_ENV + else + echo "TEST_STATUS=✅ 全部通过" >> $GITHUB_ENV + fi + - name: 使用MCDReforged打包插件 run: mcdreforged pack @@ -104,6 +131,14 @@ jobs: - 插件版本: ${{ env.VERSION || 'Development Build' }} - MCDReforged: >= 2.14 + ### 测试结果 + | 指标 | 结果 | + |------|------| + | 测试状态 | ${{ env.TEST_STATUS }} | + | 总计 | ${{ env.TEST_TOTAL }} | + | 通过 | ✅ ${{ env.TEST_PASSED }} | + | 失败 | ❌ ${{ env.TEST_FAILED }} | + ### 代码质量 | 检查项 | 状态 | |--------|------| diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41c8c0b..d751375 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,52 @@ jobs: pip install pytest pytest-asyncio>=1.0.0 - name: 运行测试 - run: pytest -v + run: pytest -v --tb=short 2>&1 | tee test-output.txt + continue-on-error: true + + - name: 生成测试报告 + if: always() + run: | + echo "## 🧪 测试报告" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # 统计结果 + TOTAL=$(grep -c "PASSED\|FAILED\|ERROR" test-output.txt || echo "0") + PASSED=$(grep -c "PASSED" test-output.txt || echo "0") + FAILED=$(grep -c "FAILED" test-output.txt || echo "0") + + echo "| 指标 | 结果 |" >> $GITHUB_STEP_SUMMARY + echo "|------|------|" >> $GITHUB_STEP_SUMMARY + echo "| 总计 | $TOTAL |" >> $GITHUB_STEP_SUMMARY + echo "| 通过 | ✅ $PASSED |" >> $GITHUB_STEP_SUMMARY + echo "| 失败 | ❌ $FAILED |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # 测试详情表格 + echo "### 详细结果" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| 测试项 | 结果 |" >> $GITHUB_STEP_SUMMARY + echo "|--------|------|" >> $GITHUB_STEP_SUMMARY + grep -E "(PASSED|FAILED|ERROR)" test-output.txt | sed 's/.*:://' | sed 's/ PASSED/ ✅ |/' | sed 's/ FAILED/ ❌ |/' | sed 's/ ERROR/ ⚠️ |/' >> $GITHUB_STEP_SUMMARY || true + echo "" >> $GITHUB_STEP_SUMMARY + + # 如果有失败,显示详细信息 + if [ "$FAILED" -gt "0" ]; then + echo "### ❌ 失败详情" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -A 20 "FAILED" test-output.txt | head -50 >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + # 设置输出变量供后续步骤使用 + echo "TEST_PASSED=$PASSED" >> $GITHUB_ENV + echo "TEST_FAILED=$FAILED" >> $GITHUB_ENV + echo "TEST_TOTAL=$TOTAL" >> $GITHUB_ENV + + # 如果有失败则返回错误 + if [ "$FAILED" -gt "0" ]; then + exit 1 + fi lint: name: 代码检查 runs-on: ubuntu-latest From def6f21b6843b2c8e8ec21222f935d7b75cfe451 Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 22:09:08 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-and-release.yml | 13 +++++++++---- .github/workflows/ci.yml | 15 ++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index ca576e8..da56e55 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -49,15 +49,20 @@ jobs: - name: 生成测试报告 if: always() run: | - TOTAL=$(grep -c "PASSED\|FAILED\|ERROR" /tmp/test-output.txt || echo "0") - PASSED=$(grep -c "PASSED" /tmp/test-output.txt || echo "0") - FAILED=$(grep -c "FAILED" /tmp/test-output.txt || echo "0") + TOTAL=$(grep -c -E "PASSED|FAILED|ERROR" /tmp/test-output.txt || true) + PASSED=$(grep -c "PASSED" /tmp/test-output.txt || true) + FAILED=$(grep -c "FAILED" /tmp/test-output.txt || true) + + # 确保变量是数字(默认为0) + TOTAL=${TOTAL:-0} + PASSED=${PASSED:-0} + FAILED=${FAILED:-0} echo "TEST_PASSED=$PASSED" >> $GITHUB_ENV echo "TEST_FAILED=$FAILED" >> $GITHUB_ENV echo "TEST_TOTAL=$TOTAL" >> $GITHUB_ENV - if [ "$FAILED" -gt "0" ]; then + if [ "$FAILED" -gt "0" ] 2>/dev/null; then echo "TEST_STATUS=❌ $FAILED 项失败" >> $GITHUB_ENV else echo "TEST_STATUS=✅ 全部通过" >> $GITHUB_ENV diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d751375..31f3601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,14 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY # 统计结果 - TOTAL=$(grep -c "PASSED\|FAILED\|ERROR" test-output.txt || echo "0") - PASSED=$(grep -c "PASSED" test-output.txt || echo "0") - FAILED=$(grep -c "FAILED" test-output.txt || echo "0") + TOTAL=$(grep -c -E "PASSED|FAILED|ERROR" test-output.txt || true) + PASSED=$(grep -c "PASSED" test-output.txt || true) + FAILED=$(grep -c "FAILED" test-output.txt || true) + + # 确保变量是数字(默认为0) + TOTAL=${TOTAL:-0} + PASSED=${PASSED:-0} + FAILED=${FAILED:-0} echo "| 指标 | 结果 |" >> $GITHUB_STEP_SUMMARY echo "|------|------|" >> $GITHUB_STEP_SUMMARY @@ -58,7 +63,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY # 如果有失败,显示详细信息 - if [ "$FAILED" -gt "0" ]; then + if [ "$FAILED" -gt "0" ] 2>/dev/null; then echo "### ❌ 失败详情" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY grep -A 20 "FAILED" test-output.txt | head -50 >> $GITHUB_STEP_SUMMARY || true @@ -71,7 +76,7 @@ jobs: echo "TEST_TOTAL=$TOTAL" >> $GITHUB_ENV # 如果有失败则返回错误 - if [ "$FAILED" -gt "0" ]; then + if [ "$FAILED" -gt "0" ] 2>/dev/null; then exit 1 fi lint: From 159e0e2d72b596d5a4845d5dc754211674f76ae4 Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 22:27:38 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9relese=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-and-release.yml | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index da56e55..47434ce 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -49,6 +49,13 @@ jobs: - name: 生成测试报告 if: always() run: | + # 获取版本号 + PLUGIN_VERSION=$(grep -o '"version": "[^"]*"' mcdreforged.plugin.json | sed 's/"version": "\([^"]*\)"/\1/') + PLUGIN_VERSION=${PLUGIN_VERSION:-"未知"} + PYTHON_VERSION=$(python --version | awk '{print $2}') + RUN_TIME=$(date -u '+%Y-%m-%d %H:%M:%S UTC') + + # 统计结果 TOTAL=$(grep -c -E "PASSED|FAILED|ERROR" /tmp/test-output.txt || true) PASSED=$(grep -c "PASSED" /tmp/test-output.txt || true) FAILED=$(grep -c "FAILED" /tmp/test-output.txt || true) @@ -58,9 +65,17 @@ jobs: PASSED=${PASSED:-0} FAILED=${FAILED:-0} + # 计算通过率 + if [ "$TOTAL" -gt "0" ]; then + PASS_RATE=$(echo "scale=1; $PASSED * 100 / $TOTAL" | bc) + else + PASS_RATE="0.0" + fi + echo "TEST_PASSED=$PASSED" >> $GITHUB_ENV echo "TEST_FAILED=$FAILED" >> $GITHUB_ENV echo "TEST_TOTAL=$TOTAL" >> $GITHUB_ENV + echo "TEST_PASS_RATE=$PASS_RATE" >> $GITHUB_ENV if [ "$FAILED" -gt "0" ] 2>/dev/null; then echo "TEST_STATUS=❌ $FAILED 项失败" >> $GITHUB_ENV @@ -68,6 +83,50 @@ jobs: echo "TEST_STATUS=✅ 全部通过" >> $GITHUB_ENV fi + # 生成 Step Summary 报告 + echo "## EasyBot-MCDR 测试报告" >> $GITHUB_STEP_SUMMARY + echo "插件版本: $PLUGIN_VERSION" >> $GITHUB_STEP_SUMMARY + echo "Python: $PYTHON_VERSION" >> $GITHUB_STEP_SUMMARY + echo "运行平台: linux" >> $GITHUB_STEP_SUMMARY + echo "测试时间: $RUN_TIME" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### 概览" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| 指标 | 结果 |" >> $GITHUB_STEP_SUMMARY + echo "|------|------|" >> $GITHUB_STEP_SUMMARY + echo "| 总计 | $TOTAL |" >> $GITHUB_STEP_SUMMARY + echo "| 通过 | $PASSED |" >> $GITHUB_STEP_SUMMARY + echo "| 失败 | $FAILED |" >> $GITHUB_STEP_SUMMARY + echo "| 通过率 | ${PASS_RATE}% |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### 测试详情" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| # | 测试项 | 结果 | 耗时 |" >> $GITHUB_STEP_SUMMARY + echo "|---|--------|------|------|" >> $GITHUB_STEP_SUMMARY + + # 解析测试输出,生成详细表格 + TEST_NUM=0 + while IFS= read -r line; do + if echo "$line" | grep -qE "(PASSED|FAILED|ERROR)"; then + TEST_NUM=$((TEST_NUM + 1)) + # 提取测试名称和结果 + TEST_NAME=$(echo "$line" | sed 's/.*:://' | sed 's/ PASSED.*//' | sed 's/ FAILED.*//' | sed 's/ ERROR.*//') + TEST_TIME=$(echo "$line" | grep -oP '[\d.]+s$' || echo "0.000s") + + if echo "$line" | grep -q "PASSED"; then + TEST_RESULT="✅ 通过" + elif echo "$line" | grep -q "FAILED"; then + TEST_RESULT="❌ 失败" + else + TEST_RESULT="⚠️ 错误" + fi + + echo "| $TEST_NUM | $TEST_NAME | $TEST_RESULT | $TEST_TIME |" >> $GITHUB_STEP_SUMMARY + fi + done < /tmp/test-output.txt + - name: 使用MCDReforged打包插件 run: mcdreforged pack From 97b1b4f425d6b955a4982830cc2fcf793e8e100b Mon Sep 17 00:00:00 2001 From: 123165 Date: Wed, 10 Jun 2026 22:28:46 +0800 Subject: [PATCH 07/11] Bump to 1.2.5 --- mcdreforged.plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index 5ef487a..bce8c36 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -1,6 +1,6 @@ { "id": "easybot_mcdr", - "version": "1.2.1", + "version": "1.2.5", "name": "EasyBot", "description": "一款集消息同步、自定义命令、绑定管理、高级权限控制、群组互动、自定义模板支持以及自定义插件支持等全方位功能于一体的服务器管理工具,全方位优化游戏社区体验!", "author": ["LBY123165", "MiuxuE"], From a9704ac0cf29bafb203473f09b2961923bffc24e Mon Sep 17 00:00:00 2001 From: 123165 Date: Sat, 13 Jun 2026 22:07:38 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=89=A7=E8=A1=8C=E3=80=81PAPI=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E3=80=81NBT=E6=95=B0=E6=8D=AE=E8=AF=BB=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=E7=8E=A9=E5=AE=B6=E7=9A=AE=E8=82=A4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 READ_NBT RPC: 支持读取玩家背包/进度/统计数据,支持在线玩家 inventory 解析 - 命令执行重构: MCDR 命令 (!! 开头) 直接通过 server.execute() 执行,不再依赖 RCON - PAPI 改进: 自动检测 Bukkit 服务端类型,非 Bukkit 支持 player_name/uuid/ip 本地替换 - 新增 SkinsRestorer 数据库读取皮肤 URL - ExecContext.callback 改为 async,适配异步 WebSocket 发送 - 上报玩家信息时附带 skin_url 和 bedrock 字段 - 移除已废弃的图片服务器和 !!say 命令相关代码 - 更新 README 文档 --- .claude/settings.local.json | 7 -- easybot_mcdr/api/player.py | 15 ++- easybot_mcdr/api/player_data.py | 82 ++++++++++++++++ easybot_mcdr/bridge_behavior.py | 3 + easybot_mcdr/impl/bridge_behavior_impl.py | 18 +++- easybot_mcdr/impl/exec_command.py | 113 +++++++++++++++++----- easybot_mcdr/impl/get_server_info.py | 9 +- easybot_mcdr/impl/papi.py | 86 ++++++++++++++-- easybot_mcdr/impl/player_events.py | 5 +- easybot_mcdr/impl/player_list.py | 96 +++++++++++++++++- easybot_mcdr/impl/rpc_handlers.py | 59 ++++++++--- easybot_mcdr/main.py | 8 +- easybot_mcdr/websocket/context.py | 4 +- easybot_mcdr/websocket/ws.py | 3 + readme.md | 31 +++--- 15 files changed, 459 insertions(+), 80 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index c31caf6..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(python test_connection.py)" - ] - } -} diff --git a/easybot_mcdr/api/player.py b/easybot_mcdr/api/player.py index c54ee78..4eb7cc9 100644 --- a/easybot_mcdr/api/player.py +++ b/easybot_mcdr/api/player.py @@ -126,12 +126,12 @@ def update_player_uuid(player: str, new_uuid: str): def on_player_joined(server, player, info: Info): logger = ServerInterface.get_instance().logger - + # Skip bot players if is_bot_player(player): logger.info(f"检测到假人玩家 {player},跳过数据处理") return - + # 统一的UUID获取逻辑 uuid = uuid_map.get(player) if uuid is None: @@ -146,23 +146,22 @@ def on_player_joined(server, player, info: Info): uuid = generate_offline_uuid(player) # 降级到离线UUID else: uuid = generate_offline_uuid(player) # 离线模式直接生成UUID - + # 更新UUID映射 update_player_uuid(player, uuid) - + # 获取IP地址 ip = "127.0.0.1" if match := re.search(r'\d+\.\d+\.\d+\.\d+', info.raw_content): ip = match.group() - + player_info = PlayerInfo(ip, player, uuid) online_players[player] = player_info cached_data[player] = player_info - + logger.info(f"玩家 {player} 加入成功: UUID={uuid}, IP={ip}") def build_player_info(player: str): - logger = ServerInterface.get_instance().logger if not check_cache(player): if player in online_players: return { @@ -172,8 +171,8 @@ def build_player_info(player: str): "skin_url": "", "bedrock": False } - logger.warning(f"玩家 {player} 未在线或无缓存数据") return None + return { "player_name": player, "player_uuid": cached_data[player].uuid, diff --git a/easybot_mcdr/api/player_data.py b/easybot_mcdr/api/player_data.py index cf4a5a8..d9c4d6e 100644 --- a/easybot_mcdr/api/player_data.py +++ b/easybot_mcdr/api/player_data.py @@ -90,6 +90,50 @@ def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: return None if type_name == "playerdata": + # 尝试通过 RCON 读取在线玩家的背包数据 + # 需要使用在线玩家的名称来读取 + from easybot_mcdr.api.player import online_players, uuid_map + + # 首先通过 UUID 找到玩家名称 + player_name = None + for name, uuid in uuid_map.items(): + if uuid == player_uuid: + player_name = name + break + + if player_name and player_name in online_players: + # 使用 data get entity 命令读取在线玩家数据 + try: + result = self._server.rcon_query( + f"data get entity {player_name}" + ) + if result and "has the following entity data" in result: + # 解析原始数据,提取 Inventory 部分 + raw_data = result.split("has the following entity data: ", 1) + if len(raw_data) > 1: + entity_data_str = raw_data[1] + # 提取 Inventory 部分 + import re + inventory_match = re.search( + r'Inventory: \[(.*?)\]', + entity_data_str, + re.DOTALL + ) + if inventory_match: + inventory_str = inventory_match.group(1) + # 解析物品列表 + inventory = self._parse_inventory_items(inventory_str) + return { + "raw": result, + "parsed": { + "Inventory": inventory + }, + "inventory": inventory + } + return {"raw": result} + except Exception as e: + logger.error(f"读取玩家背包数据失败: {e}") + pass return None if type_name == "advancements": @@ -116,6 +160,44 @@ def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: return None + def _parse_inventory_items(self, inventory_str: str) -> list: + """解析 Minecraft NBT 格式的物品列表""" + items = [] + if not inventory_str: + return items + + # 匹配每个物品: {count: 64, Slot: 0b, id: "minecraft:oak_planks"} + import re + item_pattern = re.compile(r'\{([^}]+)\}') + for match in item_pattern.finditer(inventory_str): + item_str = match.group(1) + item = {} + + # 解析 count + count_match = re.search(r'count:\s*(\d+)', item_str) + if count_match: + item['count'] = int(count_match.group(1)) + + # 解析 Slot + slot_match = re.search(r'Slot:\s*(\d+)b?', item_str) + if slot_match: + item['slot'] = int(slot_match.group(1)) + + # 解析 id + id_match = re.search(r'id:\s*"([^"]+)"', item_str) + if id_match: + item['id'] = id_match.group(1) + + # 解析 tag (如果有) + tag_match = re.search(r'tag:\s*\{([^}]+)\}', item_str) + if tag_match: + item['tag'] = tag_match.group(1) + + if item: + items.append(item) + + return items + def get_entity_data(self, player: str, path: str = "", timeout: float = 5.0) -> Optional[dict]: raw = self.get_player_info(player, path, timeout) if raw is None: diff --git a/easybot_mcdr/bridge_behavior.py b/easybot_mcdr/bridge_behavior.py index 4ba7a8d..f664134 100644 --- a/easybot_mcdr/bridge_behavior.py +++ b/easybot_mcdr/bridge_behavior.py @@ -39,3 +39,6 @@ def is_authenticated(self, name: str) -> bool: def get_player_skin(self, player_name: str) -> Optional[str]: ... + + def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: + ... diff --git a/easybot_mcdr/impl/bridge_behavior_impl.py b/easybot_mcdr/impl/bridge_behavior_impl.py index 7450b2f..1013b5a 100644 --- a/easybot_mcdr/impl/bridge_behavior_impl.py +++ b/easybot_mcdr/impl/bridge_behavior_impl.py @@ -12,11 +12,16 @@ def __init__(self, server: PluginServerInterface): def run_command(self, player_name: str, command: str, enable_papi: bool) -> str: cmd = command try: + # MCDR 命令 (!! 开头) 不经过 RCON, 直接通过 server.execute() 执行 + if cmd.strip().startswith("!!"): + self.server.execute(cmd) + return "(MCDR 命令已执行)" + # 服务端命令优先使用 RCON 获取输出 if self.server.is_rcon_running(): return str(self.server.rcon_query(cmd)) else: self.server.execute(cmd) - return "executed" + return "(命令已执行, RCON 未启用无法获取输出)" except Exception as e: return f"error: {e}" @@ -92,3 +97,14 @@ def get_player_skin(self, player_name: str) -> Optional[str]: except Exception: pass return None + + def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: + """ + 读取玩家 NBT 数据 + data_type: 0=PlayerData, 1=Advancements, 2=Statistics + """ + from easybot_mcdr.behavior_impl import get_player_data_getter + getter = get_player_data_getter() + if getter is None: + return None + return getter.read_nbt_data(player_uuid, data_type) diff --git a/easybot_mcdr/impl/exec_command.py b/easybot_mcdr/impl/exec_command.py index 43e733a..580f59d 100644 --- a/easybot_mcdr/impl/exec_command.py +++ b/easybot_mcdr/impl/exec_command.py @@ -1,37 +1,106 @@ from easybot_mcdr.websocket.context import ExecContext from easybot_mcdr.websocket.ws import EasyBotWsClient from mcdreforged.api.all import * +from mcdreforged.command.command_source import CommandSource + + +class _OutputCaptureSource(CommandSource): + """自定义命令源, 捕获 reply() 输出用于获取 MCDR 命令执行结果""" + + def __init__(self, server): + self._server = server.as_basic_server_interface() + self._output = [] + + def get_server(self): + return self._server + + def get_permission_level(self): + return 4 # CONSOLE level (highest) + + def reply(self, text, **kwargs): + self._output.append(str(text)) + + def get_output(self) -> str: + return "\n".join(self._output) if self._output else "" + + +def _is_mcdr_command(command: str) -> bool: + """判断是否为 MCDR 命令 (以 !! 开头)""" + return command.strip().startswith("!!") + + +async def _execute_mcdr_command_with_output(server, command: str) -> str: + """ + 执行 MCDR 命令并捕获输出。 + + 原理: + - 创建一个 _OutputCaptureSource 作为命令源 + - 通过 server.execute_command() 将命令送入 MCDR 命令树 + - 命令处理器调用 source.reply() 时, 输出被 _OutputCaptureSource 捕获 + - 返回捕获到的输出文本 + """ + source = _OutputCaptureSource(server) + try: + server.execute_command(command, source) + output = source.get_output() + return output if output else "(MCDR 命令已执行, 无输出)" + except Exception as e: + return f"(MCDR 命令执行失败: {e})" + @EasyBotWsClient.listen_exec_op("RUN_COMMAND") -async def exec_bind_success_notify(ctx: ExecContext, data:dict, _): +async def exec_bind_success_notify(ctx: ExecContext, data: dict, _): server = ServerInterface.get_instance() logger = server.logger - if not server.is_rcon_running(): - logger.error(f"RCON未开启,无法执行命令 -> {data['command']}") - ctx.callback({ - "success": False, - "text": "目标MCDR未开启未开启RCON,无法执行命令!" - }) - return command = data["command"] - if data["enable_papi"]: + + # Placeholder 变量替换 + if data.get("enable_papi"): from easybot_mcdr.impl.papi import run_placeholder command = await run_placeholder(command, data["player_name"]) + try: - if not server.is_rcon_running(): - raise Exception("RCON未启用") - - resp = server.rcon_query(command) - logger.debug(f"执行命令 -> {command}") - logger.debug(f"执行结果 -> {resp}") - await ctx.callback({ - "success": True, - "text": resp - }) + if _is_mcdr_command(command): + # ======================================== + # MCDR 命令处理 (!! 开头的命令) + # ======================================== + # MCDR 命令不会到达服务端, 而是由 MCDR CommandManager 处理 + # 使用 OutputCaptureSource 捕获 source.reply() 输出 + logger.debug(f"检测到 MCDR 命令 -> {command}") + + output = await _execute_mcdr_command_with_output(server, command) + + logger.debug(f"MCDR 命令执行完成, 输出: {output}") + await ctx.callback({ + "success": True, + "text": output + }) + else: + # ======================================== + # 服务端命令处理 (非 !! 开头的命令) + # ======================================== + # 服务端命令需要 RCON 来执行并获取输出 + if not server.is_rcon_running(): + logger.error(f"RCON 未开启, 无法执行服务端命令 -> {command}") + await ctx.callback({ + "success": False, + "text": "目标 MCDR 未开启 RCON, 无法执行命令!" + }) + return + + # 通过 RCON 执行命令并获取输出 + resp = server.rcon_query(command) + logger.debug(f"执行服务端命令 -> {command}") + logger.debug(f"执行结果 -> {resp}") + await ctx.callback({ + "success": True, + "text": resp if resp is not None else "" + }) + except Exception as e: - logger.warning(f"RCON查询失败: {str(e)}") + logger.warning(f"命令执行失败: {str(e)}") await ctx.callback({ "success": False, - "text": f"RCON查询失败: {str(e)}" - }) \ No newline at end of file + "text": f"命令执行失败: {str(e)}" + }) diff --git a/easybot_mcdr/impl/get_server_info.py b/easybot_mcdr/impl/get_server_info.py index bbd3495..f916ece 100644 --- a/easybot_mcdr/impl/get_server_info.py +++ b/easybot_mcdr/impl/get_server_info.py @@ -27,11 +27,18 @@ async def exec_get_server_info(ctx: ExecContext, data:dict, _): has_skins_restorer = sr_found try: + # 检测 PAPI 支持 (仅 Bukkit 系列) + try: + from mcdreforged.handler.impl.bukkit_handler import BukkitHandler + is_bukkit = isinstance(server.get_server_handler(), BukkitHandler) + except Exception: + is_bukkit = False + packet = { "server_name": "mcdr", "server_version": f"MCDR {server.get_plugin_metadata('mcdreforged').version}", "plugin_version": get_plugin_version(), - "is_papi_supported": False, + "is_papi_supported": is_bukkit, "is_command_supported": True, "has_geyser": False, "has_skins_restorer": sr_found, diff --git a/easybot_mcdr/impl/papi.py b/easybot_mcdr/impl/papi.py index 833c0c8..d0085d2 100644 --- a/easybot_mcdr/impl/papi.py +++ b/easybot_mcdr/impl/papi.py @@ -4,31 +4,82 @@ from easybot_mcdr.websocket.ws import EasyBotWsClient from mcdreforged.api.all import * +# 缓存: 服务端是否支持 PAPI (Bukkit 系列) +_papi_supported_cache = None + + +def _is_bukkit_server() -> bool: + """检测当前服务端是否为 Bukkit 系列 (Spigot/Paper 等)""" + global _papi_supported_cache + if _papi_supported_cache is not None: + return _papi_supported_cache + + try: + server = ServerInterface.get_instance() + handler = server.get_server_handler() + # BukkitHandler 及其子类 (Spigot, Paper 等) 都继承自 BukkitHandler + from mcdreforged.handler.impl.bukkit_handler import BukkitHandler + _papi_supported_cache = isinstance(handler, BukkitHandler) + except Exception: + _papi_supported_cache = False + + return _papi_supported_cache + def get_placeholders(text: str) -> list[str]: return re.findall(r"%\w+%", text) def _local_replace(player: str, text: str) -> str: + """ + 本地基础变量替换 (所有服务端类型通用) + 支持的变量: + %player_name% - 玩家名 + %player_uuid% - 玩家 UUID + %player_ip% - 玩家 IP + """ server = ServerInterface.get_instance() logger = server.logger query_text = text + + # 从玩家 API 获取数据 + try: + from easybot_mcdr.api.player import online_players, uuid_map + player_info = online_players.get(player) + uuid = uuid_map.get(player, "unknown") + ip = player_info.ip if player_info else "unknown" + except Exception: + uuid = "unknown" + ip = "unknown" + for placeholder in get_placeholders(query_text): - if placeholder.lower() == "%player_name%": + lower = placeholder.lower() + if lower == "%player_name%": query_text = query_text.replace(placeholder, player) + elif lower == "%player_uuid%": + query_text = query_text.replace(placeholder, uuid) + elif lower == "%player_ip%": + query_text = query_text.replace(placeholder, ip) else: - logger.warning(f"不支持的变量: {placeholder} [注意, 仅提供基础变量替换]") + logger.warning(f"不支持的变量: {placeholder} [仅支持基础变量: player_name, player_uuid, player_ip]") return query_text async def run_placeholder(player: str, text: str, use_rcon: bool = True) -> str: """ - 优先通过 RCON 调用 PAPI (papi parse ""),失败则回退本地基础替换。 - 适用于 Fabric 后端:需后端安装 PlaceholderAPI 并允许控制台执行 papi 命令。 + 占位符解析: + - Bukkit 服务端: 优先通过 RCON 调用 PAPI (papi parse "") + - 非 Bukkit 服务端: PAPI 不可用, 使用本地基础替换 """ server = ServerInterface.get_instance() logger = server.logger + # 检测服务端类型 + if not _is_bukkit_server(): + logger.debug(f"PAPI 不可用 (非 Bukkit 服务端), 使用本地替换: {text}") + return _local_replace(player, text) + + # Bukkit 服务端: 优先通过 RCON 调用 PAPI if use_rcon and server.is_rcon_running(): cmd = f'papi parse {player} "{text}"' try: @@ -58,7 +109,30 @@ def run_placeholder_blocking(player: str, text: str, use_rcon: bool = True, time @EasyBotWsClient.listen_exec_op("PLACEHOLDER_API_QUERY") async def on_placeholder_api_query(ctx: ExecContext, data: dict, _): query_text = data.get("query_text", "") + player = data.get("player_name", "") + + if not _is_bukkit_server(): + await ctx.callback({ + "success": False, + "text": f"PAPI 不可用 (非 Bukkit 服务端): {query_text}" + }) + return + + # Bukkit: 尝试通过 RCON 查询 + server = ServerInterface.get_instance() + if server.is_rcon_running(): + try: + resp = server.rcon_query(f'papi parse {player} "{query_text}"') + if resp is not None: + await ctx.callback({ + "success": True, + "text": str(resp).strip() + }) + return + except Exception as e: + server.logger.warning(f"PAPI RCON 查询失败: {e}") + await ctx.callback({ "success": False, - "text": f"PAPI unavailable: {query_text}" - }) \ No newline at end of file + "text": f"PAPI 查询失败: {query_text}" + }) diff --git a/easybot_mcdr/impl/player_events.py b/easybot_mcdr/impl/player_events.py index abd5a2f..e48f905 100644 --- a/easybot_mcdr/impl/player_events.py +++ b/easybot_mcdr/impl/player_events.py @@ -29,7 +29,10 @@ async def on_player_joined(server: PluginServerInterface, player: str, info: Inf return server.logger.info(f"玩家 {player} 已加入并缓存: UUID={player_info['player_uuid']}, IP={player_info['ip']}") res = await wsc.login(player) - if res["kick"]: + if res is None: + server.logger.error(f"玩家 {player} 登录验证返回空响应") + return + if res.get("kick", False): kick_msg = res.get("kick_message", "验证失败") server.logger.info(f"检测到玩家 {player} 需要被踢出,等待加载延迟...") kick_map[player] = time.time() diff --git a/easybot_mcdr/impl/player_list.py b/easybot_mcdr/impl/player_list.py index 10dcbc6..b8fd108 100644 --- a/easybot_mcdr/impl/player_list.py +++ b/easybot_mcdr/impl/player_list.py @@ -36,14 +36,106 @@ async def check_premium(name: str) -> bool: return is_premium +def _get_sr_skin(name: str) -> str: + """从 SkinsRestorer SQLite 数据库读取皮肤 URL""" + try: + from mcdreforged.api.all import ServerInterface + server = ServerInterface.get_instance() + working_dir = server.get_mcdr_config()["working_directory"] + plugins_dir = os.path.join(working_dir, "plugins") + + # 新版路径 + db_path = os.path.join(plugins_dir, "SkinsRestorer", "skins", "skins.db") + if not os.path.isfile(db_path): + # 旧版路径 + db_path = os.path.join(plugins_dir, "SkinsRestorer", "Skins.db") + if not os.path.isfile(db_path): + return "" + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 获取所有表名 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = [row[0] for row in cursor.fetchall()] + + skin_url = "" + + # 尝试新版表 sr_skins + if "sr_skins" in tables: + cursor.execute("PRAGMA table_info(sr_skins)") + columns = [col[1] for col in cursor.fetchall()] + + nick_col = next((c for c in columns if c in ("nick", "name", "player")), None) + skin_col = next((c for c in columns if c in ("skin", "value", "texture", "url")), None) + + if nick_col and skin_col: + cursor.execute(f"SELECT `{skin_col}` FROM sr_skins WHERE `{nick_col}` = ?", (name,)) + row = cursor.fetchone() + if row and row[0]: + val = str(row[0]) + if val.startswith("http"): + skin_url = val + elif len(val) > 100: + # base64 编码的 textures JSON + try: + decoded = base64.b64decode(val).decode("utf-8") + data = json.loads(decoded) + skin_url = data.get("textures", {}).get("SKIN", {}).get("url", "") + except Exception: + pass + + # 尝试旧版表 skins + if not skin_url and "skins" in tables: + cursor.execute("PRAGMA table_info(skins)") + columns = [col[1] for col in cursor.fetchall()] + + name_col = next((c for c in columns if c in ("name", "nick", "player")), None) + value_col = next((c for c in columns if c in ("value", "skin", "texture", "url")), None) + + if name_col and value_col: + cursor.execute(f"SELECT `{value_col}` FROM skins WHERE `{name_col}` = ?", (name,)) + row = cursor.fetchone() + if row and row[0]: + val = str(row[0]) + if val.startswith("http"): + skin_url = val + elif len(val) > 100: + try: + decoded = base64.b64decode(val).decode("utf-8") + data = json.loads(decoded) + skin_url = data.get("textures", {}).get("SKIN", {}).get("url", "") + except Exception: + pass + + conn.close() + + if skin_url: + server.logger.info(f"[EasyBot-SKIN] SR 找到皮肤: {name} -> {skin_url[:60]}") + return skin_url + + except Exception as e: + try: + from mcdreforged.api.all import ServerInterface + ServerInterface.get_instance().logger.debug(f"[EasyBot-SKIN] SR 读取失败: {e}") + except Exception: + pass + return "" + + async def try_get_skin(name, uuid=""): online = get_online_mode() has_sr = get_skins_restorer() - if online: + # SkinsRestorer: 从数据库读取自定义皮肤 + if has_sr: + sr_skin = _get_sr_skin(name) + if sr_skin: + return sr_skin + # 数据库没找到,fallback return f"https://mineskin.eu/download/{name}" - if has_sr: + if online: return f"https://mineskin.eu/download/{name}" # 离线模式无皮肤站: 查询 Mojang 判断是否正版 diff --git a/easybot_mcdr/impl/rpc_handlers.py b/easybot_mcdr/impl/rpc_handlers.py index cac8f0e..676c645 100644 --- a/easybot_mcdr/impl/rpc_handlers.py +++ b/easybot_mcdr/impl/rpc_handlers.py @@ -29,18 +29,6 @@ async def sync_segments(ctx, data, session_info): await ctx.callback({"success": True}) -@bridge_rpc("RUN_COMMAND", description="Run command (PAPI disabled)") -async def run_command(ctx, data, session_info): - player = data.get("player_name") or "" - command = data.get("command") or "" - enable_papi = False - try: - result = _behavior().run_command(player, command, enable_papi) - await ctx.callback({"success": True, "text": result}) - except Exception as e: - await ctx.callback({"success": False, "text": str(e)}) - - @bridge_rpc("PAPI_QUERY", description="PAPI disabled") async def papi_query(ctx, data, session_info): query = data.get("query") or "" @@ -112,3 +100,50 @@ async def get_player_skin(ctx, data, session_info): player_name = data.get("player_name") or "" skin_url = _behavior().get_player_skin(player_name) await ctx.callback({"success": True, "skin_url": skin_url or ""}) + + +@bridge_rpc("READ_NBT_DATA", description="Read player NBT data (0=PlayerData, 1=Advancements, 2=Statistics)") +async def read_nbt_data(ctx, data, session_info): + player_uuid = data.get("player_uuid") or "" + data_type = data.get("data_type", 0) + + # 支持字符串类型的 data_type 转换为数字 + DATA_TYPE_MAP = { + "PlayerData": 0, + "Advancements": 1, + "Statistics": 2, + "playerdata": 0, + "advancements": 1, + "statistics": 2, + } + if isinstance(data_type, str): + data_type = DATA_TYPE_MAP.get(data_type, 0) + + try: + result = _behavior().read_nbt_data(player_uuid, data_type) + if result is None: + await ctx.callback({ + "success": False, + "message": "not found", + "result": 2 # ReadNbtResult.Notfound + }) + else: + # 构建响应数据,确保包含 inventory 字段 + response_data = { + "success": True, + "message": "found", + "result": 1, # ReadNbtResult.Succeeded + "data": result + } + # 如果有解析后的 inventory,添加到顶层 + if "inventory" in result: + response_data["inventory"] = result["inventory"] + elif "parsed" in result and "Inventory" in result.get("parsed", {}): + response_data["inventory"] = result["parsed"]["Inventory"] + await ctx.callback(response_data) + except Exception as e: + await ctx.callback({ + "success": False, + "message": str(e), + "result": 3 # ReadNbtResult.Error + }) diff --git a/easybot_mcdr/main.py b/easybot_mcdr/main.py index ba394fd..10fa9e0 100644 --- a/easybot_mcdr/main.py +++ b/easybot_mcdr/main.py @@ -74,6 +74,10 @@ async def on_load(server: PluginServerInterface, prev_module): # 加载配置 load_config(server) + # 初始化 behavior_impl (PlayerDataGetter 等) + from easybot_mcdr import behavior_impl + behavior_impl.set_server(server) + # 启动本地图片服务器(如果启用了图片上传) if get_config().get("image_upload", {}).get("enabled"): from easybot_mcdr.impl.chat_image import start_local_image_server @@ -560,7 +564,9 @@ def register_event_listeners(server: PluginServerInterface): server.register_event_listener('player_death', on_player_death) server.register_event_listener('mcdr.player_left', on_player_left) server.register_event_listener('player_left', on_player_left) - server.register_event_listener('player_joined', on_player_joined) + # 注意: mcdr.player_joined 事件已在 player.py 的 init_player_api() 中以优先级1注册 + # 这里使用优先级10,确保在 player.py 的数据更新之后执行上报逻辑 + server.register_event_listener('mcdr.player_joined', on_player_joined, priority=10) server.register_event_listener('mcdr.user_info', on_user_info) server.logger.info("事件监听器注册完成") diff --git a/easybot_mcdr/websocket/context.py b/easybot_mcdr/websocket/context.py index 83b7145..7216d7a 100644 --- a/easybot_mcdr/websocket/context.py +++ b/easybot_mcdr/websocket/context.py @@ -7,11 +7,11 @@ def __init__(self, callback_id: str, exec_op: str, wsc): self.exec_op = exec_op self.ws = wsc - def callback(self, data: dict): + async def callback(self, data: dict): packet = { "op": 5, "callback_id": self.callback_id, "exec_op": self.exec_op } packet.update(data) - return self.ws.send(json.dumps(packet)) \ No newline at end of file + await self.ws.send(json.dumps(packet)) \ No newline at end of file diff --git a/easybot_mcdr/websocket/ws.py b/easybot_mcdr/websocket/ws.py index 7ce1654..43b1510 100644 --- a/easybot_mcdr/websocket/ws.py +++ b/easybot_mcdr/websocket/ws.py @@ -353,10 +353,13 @@ async def report_player(self, player_name: str): except Exception: pass return None + # 发送完整的玩家信息,包括所有必要字段 await self._send_packet("REPORT_PLAYER", { "player_name": player_name, "player_uuid": info["player_uuid"], "player_ip": info["ip"], + "skin_url": info.get("skin_url", ""), + "bedrock": info.get("bedrock", False), }) return info diff --git a/readme.md b/readme.md index ef26ba1..f0a0060 100644 --- a/readme.md +++ b/readme.md @@ -43,8 +43,6 @@ EasyBot-MCDR 通过 WebSocket 将 Minecraft 服务器与 EasyBot 中心服务器 ### 其他 - 配置热重载(`!!ez reload`) -- 内置本地图片服务器,支持局域网内图片访问 -- 可选 imgbb 图床上传,支持公网图片分享 ## 环境要求 @@ -85,7 +83,6 @@ EasyBot-MCDR 通过 WebSocket 将 Minecraft 服务器与 EasyBot 中心服务器 | :--- | :--- | :--- | | `!!bind` / `!!ez bind` | 绑定社交平台账号 | 所有玩家 | | `!!unbind` / `!!ez unbind` | 解绑账号 | 所有玩家 | -| `!!say <消息>` / `!!esay <消息>` | 跨服发送消息 | 所有玩家 | | `!!ez reload` | 重载配置 | OP | ## 配置说明 @@ -140,7 +137,7 @@ EasyBot-MCDR 通过 WebSocket 将 Minecraft 服务器与 EasyBot 中心服务器 | `bot_filter.enabled` | bool | `true` | 启用假玩家过滤 | | `bot_filter.prefixes` | list | `["Bot_","BOT_","bot_"]` | 识别为机器人的名称前缀 | -### 图片上传(可选) +### 图片上传(暂不可用) 启用后,QQ 群中的图片将在游戏内通过 [ChatImage](https://github.com/kitUIN/ChatImage) 模组渲染。 @@ -156,19 +153,19 @@ EasyBot-MCDR 通过 WebSocket 将 Minecraft 服务器与 EasyBot 中心服务器 `EasyBot-MCDR` 是 `EasyBot` 插件的 MCDR 分支,与 `EasyBot-Bukkit` 功能有部分差异: | 特性 | MCDR 支持 | 说明 | -| :--- |:-------:| :--- | -| 消息同步 | ✅ | | -| 进入退出通知 | ✅ | | -| 强制绑定 | ✅ | | -| 命令绑定账号 | ✅ | | -| 命令模式消息同步 | ✅ | | -| 热重载 | ✅ | | -| 执行命令 | ✅ | | -| 绑定时执行命令 | ✅ | | -| 联动原版白名单 | ✅ | | -| 解绑时执行命令 | ✅ | | -| 死亡同步 | x | 不同服务端实现原理不同,无法稳定判断死亡原因 | -| PlaceholderAPI | ⚠️ | 目前仅支持 `%player_name%`,完整支持计划中 | +| :--- | :---: | :--- | +| 消息同步 | ✅ | | +| 进入退出通知 | ✅ | | +| 强制绑定 | ✅ | | +| 命令绑定账号 | ✅ | | +| 命令模式消息同步 | ✅ | | +| 热重载 | ✅ | | +| 执行命令 | ✅ | | +| 绑定时执行命令 | ✅ | | +| 联动原版白名单 | ✅ | | +| 解绑时执行命令 | ✅ | | +| 死亡同步 | :x: | 不同服务端实现原理不同,无法稳定判断死亡原因 | +| PlaceholderAPI | ⚠️ | Bukkit 服务端通过 RCON 调用完整 PAPI,非 Bukkit 支持 `%player_name%`、`%player_uuid%`、`%player_ip%` 本地替换 | ## 开发 From 7d0418f9e4d61a2c3b20fffaf73adba859a61f5d Mon Sep 17 00:00:00 2001 From: 123165 Date: Sat, 13 Jun 2026 22:12:23 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=E7=9A=84=E7=BC=BA=E5=B0=91=20RUN=5FCOMMAND=20RPC=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easybot_mcdr/impl/exec_command.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/easybot_mcdr/impl/exec_command.py b/easybot_mcdr/impl/exec_command.py index 580f59d..be0bec4 100644 --- a/easybot_mcdr/impl/exec_command.py +++ b/easybot_mcdr/impl/exec_command.py @@ -1,7 +1,6 @@ -from easybot_mcdr.websocket.context import ExecContext -from easybot_mcdr.websocket.ws import EasyBotWsClient from mcdreforged.api.all import * from mcdreforged.command.command_source import CommandSource +from easybot_mcdr.rpc import bridge_rpc class _OutputCaptureSource(CommandSource): @@ -48,8 +47,8 @@ async def _execute_mcdr_command_with_output(server, command: str) -> str: return f"(MCDR 命令执行失败: {e})" -@EasyBotWsClient.listen_exec_op("RUN_COMMAND") -async def exec_bind_success_notify(ctx: ExecContext, data: dict, _): +@bridge_rpc("RUN_COMMAND", description="Execute a command on the server") +async def run_command(ctx, data, session_info): server = ServerInterface.get_instance() logger = server.logger From d8c3caa53c9ddfaa78df63ee6e84fec4c244c0ea Mon Sep 17 00:00:00 2001 From: 123165 Date: Sun, 14 Jun 2026 16:14:16 +0800 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=83=8C?= =?UTF-8?q?=E5=8C=85=E9=A2=84=E8=A7=88=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easybot_mcdr/api/player_data.py | 106 +++++++++------------- easybot_mcdr/bridge_behavior.py | 2 +- easybot_mcdr/impl/bridge_behavior_impl.py | 44 +++++---- easybot_mcdr/impl/rpc_handlers.py | 33 ++++--- 4 files changed, 91 insertions(+), 94 deletions(-) diff --git a/easybot_mcdr/api/player_data.py b/easybot_mcdr/api/player_data.py index d9c4d6e..75c8b11 100644 --- a/easybot_mcdr/api/player_data.py +++ b/easybot_mcdr/api/player_data.py @@ -6,6 +6,37 @@ logger = logging.getLogger("EasyBot") + +def parse_minecraft_json(text: str) -> Optional[dict]: + """ + 解析 Minecraft NBT 格式的数据并转换为 JSON + 例如: "Alex has the following entity data: {a: 0b, big: 2.99E7, ...}" + 返回: {"a": 0, "big": 2.99E7, ...} + """ + try: + import hjson + import collections + + # 移除命令结果前缀 + text = re.sub(r'^[^ ]* has the following entity data: ', '', text) + + # 移除数字后的字母后缀 (0b -> 0, 300s -> 300, etc.) + text = re.sub(r'(([{\[:,]|^) *[+-]?\d+(\.\d*?)?(E[+-]?\d+)?)([bsLdf])', r'\1', text) + + # 移除数组头 ([I; -> [) + text = re.sub(r'(?<=\[)[IL];', '', text) + + # 移除折叠标记 (<...> -> "") + text = re.sub(r'<\.\.\.>', '', text) + + value = hjson.loads(text) + if isinstance(value, collections.OrderedDict): + return dict(value) + return value + except Exception as e: + logger.error(f"解析 Minecraft JSON 失败: {e}") + return None + NBT_DATA_TYPE_MAP = { 0: "playerdata", # PlayerData 1: "advancements", # Advancements @@ -108,29 +139,10 @@ def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: f"data get entity {player_name}" ) if result and "has the following entity data" in result: - # 解析原始数据,提取 Inventory 部分 - raw_data = result.split("has the following entity data: ", 1) - if len(raw_data) > 1: - entity_data_str = raw_data[1] - # 提取 Inventory 部分 - import re - inventory_match = re.search( - r'Inventory: \[(.*?)\]', - entity_data_str, - re.DOTALL - ) - if inventory_match: - inventory_str = inventory_match.group(1) - # 解析物品列表 - inventory = self._parse_inventory_items(inventory_str) - return { - "raw": result, - "parsed": { - "Inventory": inventory - }, - "inventory": inventory - } - return {"raw": result} + # 使用 MinecraftJsonParser 解析 NBT 数据 + parsed_data = parse_minecraft_json(result) + if parsed_data: + return {"parsed": parsed_data} except Exception as e: logger.error(f"读取玩家背包数据失败: {e}") pass @@ -142,7 +154,10 @@ def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: f"data get storage minecraft:player_data {player_uuid}.advancements" ) if result: - return {"raw": result} + # 使用 MinecraftJsonParser 解析数据 + parsed_data = parse_minecraft_json(result) + if parsed_data: + return {"parsed": parsed_data} except Exception: pass return None @@ -153,51 +168,16 @@ def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: f"data get storage minecraft:player_data {player_uuid}.stats" ) if result: - return {"raw": result} + # 使用 MinecraftJsonParser 解析数据 + parsed_data = parse_minecraft_json(result) + if parsed_data: + return {"parsed": parsed_data} except Exception: pass return None return None - def _parse_inventory_items(self, inventory_str: str) -> list: - """解析 Minecraft NBT 格式的物品列表""" - items = [] - if not inventory_str: - return items - - # 匹配每个物品: {count: 64, Slot: 0b, id: "minecraft:oak_planks"} - import re - item_pattern = re.compile(r'\{([^}]+)\}') - for match in item_pattern.finditer(inventory_str): - item_str = match.group(1) - item = {} - - # 解析 count - count_match = re.search(r'count:\s*(\d+)', item_str) - if count_match: - item['count'] = int(count_match.group(1)) - - # 解析 Slot - slot_match = re.search(r'Slot:\s*(\d+)b?', item_str) - if slot_match: - item['slot'] = int(slot_match.group(1)) - - # 解析 id - id_match = re.search(r'id:\s*"([^"]+)"', item_str) - if id_match: - item['id'] = id_match.group(1) - - # 解析 tag (如果有) - tag_match = re.search(r'tag:\s*\{([^}]+)\}', item_str) - if tag_match: - item['tag'] = tag_match.group(1) - - if item: - items.append(item) - - return items - def get_entity_data(self, player: str, path: str = "", timeout: float = 5.0) -> Optional[dict]: raw = self.get_player_info(player, path, timeout) if raw is None: diff --git a/easybot_mcdr/bridge_behavior.py b/easybot_mcdr/bridge_behavior.py index f664134..08734e5 100644 --- a/easybot_mcdr/bridge_behavior.py +++ b/easybot_mcdr/bridge_behavior.py @@ -37,7 +37,7 @@ def module_is_enabled(self, name: str) -> bool: def is_authenticated(self, name: str) -> bool: ... - def get_player_skin(self, player_name: str) -> Optional[str]: + def get_player_skin(self, player_name: str) -> Optional[dict]: ... def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: diff --git a/easybot_mcdr/impl/bridge_behavior_impl.py b/easybot_mcdr/impl/bridge_behavior_impl.py index 1013b5a..049afff 100644 --- a/easybot_mcdr/impl/bridge_behavior_impl.py +++ b/easybot_mcdr/impl/bridge_behavior_impl.py @@ -73,29 +73,37 @@ def module_is_enabled(self, name: str) -> bool: def is_authenticated(self, name: str) -> bool: return False - def get_player_skin(self, player_name: str) -> Optional[str]: + def get_player_skin(self, player_name: str) -> Optional[dict]: from easybot_mcdr.impl.get_server_info import get_online_mode, get_skins_restorer online = get_online_mode() has_sr = get_skins_restorer() - if online or has_sr: - return f"https://mineskin.eu/download/{player_name}" - - # 离线模式无皮肤站: 查询 Mojang 判断是否正版 - from easybot_mcdr.impl.player_list import _check_premium_sync - if _check_premium_sync(player_name): - return f"https://mineskin.eu/download/{player_name}" + skin_url = None + cape_url = None - # 非正版: 尝试通过 UUID 获取头像 - try: - players = self.server.get_online_players() - for p in players: - if hasattr(p, 'name') and p.name == player_name: - return f"https://mc-heads.net/skin/{p.uuid}" - if str(p) == player_name: - return f"https://mc-heads.net/skin/{p.uuid}" - except Exception: - pass + if online or has_sr: + skin_url = f"https://mineskin.eu/download/{player_name}" + else: + # 离线模式无皮肤站: 查询 Mojang 判断是否正版 + from easybot_mcdr.impl.player_list import _check_premium_sync + if _check_premium_sync(player_name): + skin_url = f"https://mineskin.eu/download/{player_name}" + else: + # 非正版: 尝试通过 UUID 获取头像 + try: + players = self.server.get_online_players() + for p in players: + if hasattr(p, 'name') and p.name == player_name: + skin_url = f"https://mc-heads.net/skin/{p.uuid}" + break + if str(p) == player_name: + skin_url = f"https://mc-heads.net/skin/{p.uuid}" + break + except Exception: + pass + + if skin_url: + return {"skin_url": skin_url, "cape_url": cape_url} return None def read_nbt_data(self, player_uuid: str, data_type: int) -> Optional[dict]: diff --git a/easybot_mcdr/impl/rpc_handlers.py b/easybot_mcdr/impl/rpc_handlers.py index 676c645..427a8db 100644 --- a/easybot_mcdr/impl/rpc_handlers.py +++ b/easybot_mcdr/impl/rpc_handlers.py @@ -98,8 +98,21 @@ async def is_authenticated(ctx, data, session_info): @bridge_rpc("GET_PLAYER_SKIN", description="Get player skin URL") async def get_player_skin(ctx, data, session_info): player_name = data.get("player_name") or "" - skin_url = _behavior().get_player_skin(player_name) - await ctx.callback({"success": True, "skin_url": skin_url or ""}) + skin_data = _behavior().get_player_skin(player_name) + if skin_data: + await ctx.callback({ + "success": True, + "message": "found", + "result": 1, # Succeeded + "skin_url": skin_data.get("skin_url", ""), + "cape_url": skin_data.get("cape_url", "") + }) + else: + await ctx.callback({ + "success": False, + "message": "not found", + "result": 2 # Notfound + }) @bridge_rpc("READ_NBT_DATA", description="Read player NBT data (0=PlayerData, 1=Advancements, 2=Statistics)") @@ -128,19 +141,15 @@ async def read_nbt_data(ctx, data, session_info): "result": 2 # ReadNbtResult.Notfound }) else: - # 构建响应数据,确保包含 inventory 字段 - response_data = { + # 直接返回解析后的 JSON 数据作为 data 字段 + # 服务端期望 data 字段是解析后的 JsonObject + parsed_data = result.get("parsed", {}) + await ctx.callback({ "success": True, "message": "found", "result": 1, # ReadNbtResult.Succeeded - "data": result - } - # 如果有解析后的 inventory,添加到顶层 - if "inventory" in result: - response_data["inventory"] = result["inventory"] - elif "parsed" in result and "Inventory" in result.get("parsed", {}): - response_data["inventory"] = result["parsed"]["Inventory"] - await ctx.callback(response_data) + "data": parsed_data # 直接返回解析后的 JSON 对象 + }) except Exception as e: await ctx.callback({ "success": False, From 3cea8052199dea9eecc1053bfd2c3725a7f96b57 Mon Sep 17 00:00:00 2001 From: 123165 Date: Sun, 14 Jun 2026 16:36:16 +0800 Subject: [PATCH 11/11] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=BF=AE=E6=94=B9=E7=BC=96=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easybot_mcdr/impl/player_events.py | 30 +++++++++++++ easybot_mcdr/main.py | 70 ++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/easybot_mcdr/impl/player_events.py b/easybot_mcdr/impl/player_events.py index e48f905..4d7d17c 100644 --- a/easybot_mcdr/impl/player_events.py +++ b/easybot_mcdr/impl/player_events.py @@ -55,6 +55,9 @@ async def on_player_joined(server: PluginServerInterface, player: str, info: Inf # 检查 RCON 配置并提示管理员 notify_rcon_not_configured(server, player) + # 检查编码配置是否需要重启生效 + notify_encoding_config_reload(server, player) + except Exception as e: server.logger.error(f"处理玩家 {player} 加入时出错: {e}") import traceback @@ -253,3 +256,30 @@ def notify_rcon_not_configured(server: PluginServerInterface, player: str): _rcon_notify_cooldown[player] = now except Exception: pass + + +# 编码配置重载提示冷却: {player: last_notify_time} +_encoding_notify_cooldown = {} +_ENCODING_NOTIFY_INTERVAL = 300 # 5分钟内不重复提示同一玩家 + + +def notify_encoding_config_reload(server: PluginServerInterface, player: str): + """当编码配置被修改时,在游戏内提示管理员执行 reload""" + # 检查是否有编码配置需要重载 + if not getattr(server, '_easybot_encoding_fixed', False): + return + + now = time.time() + last = _encoding_notify_cooldown.get(player, 0) + if now - last < _ENCODING_NOTIFY_INTERVAL: + return + + # 只通知权限等级 >= 2 的玩家(管理员) + try: + perm_level = server.get_permission_level(player) + if perm_level >= 2: + server.tell(player, "§e[EasyBot] §c已自动修改编码配置来防止可能出现的乱码情况。") + server.tell(player, "§e[EasyBot] 请执行 §b!!MCDR reload config §e使配置生效") + _encoding_notify_cooldown[player] = now + except Exception: + pass diff --git a/easybot_mcdr/main.py b/easybot_mcdr/main.py index 10fa9e0..5abdfd7 100644 --- a/easybot_mcdr/main.py +++ b/easybot_mcdr/main.py @@ -64,16 +64,76 @@ def is_bot_player(player: str) -> bool: return any(player.startswith(prefix) for prefix in prefixes) +def fix_mcdr_encoding(server: PluginServerInterface): + """检查并修复 MCDR 的编码配置,确保能正确处理中文""" + try: + import os + config_path = os.path.join(os.getcwd(), "config.yml") + + if not os.path.exists(config_path): + server.logger.debug("未找到 MCDR config.yml,跳过编码检查") + return + + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + # 检查 decoding 配置 + # 期望格式: decoding: ['utf8', 'gbk'] + lines = content.split("\n") + modified = False + new_lines = [] + + for line in lines: + stripped = line.strip() + # 匹配 decoding: utf8 或 decoding: "utf8" 等格式 + if stripped.startswith("decoding:") and "gbk" not in stripped: + # 检查是否是单个值(不是列表) + value = stripped.split(":", 1)[1].strip() + if value.startswith("["): + # 已经是列表格式,但没有 gbk + if "gbk" not in value: + # 在列表中添加 gbk + value = value.rstrip("]").rstrip() + new_line = line.replace(stripped, f"decoding: {value}, 'gbk']") + new_lines.append(new_line) + modified = True + server.logger.info(f"已修复 MCDR decoding 配置: 添加 gbk 支持") + continue + else: + # 单个值格式,改为列表 + new_line = line.replace(stripped, f"decoding: ['utf8', 'gbk']") + new_lines.append(new_line) + modified = True + server.logger.info(f"已修复 MCDR decoding 配置: {value} -> ['utf8', 'gbk']") + continue + new_lines.append(line) + + if modified: + with open(config_path, "w", encoding="utf-8") as f: + f.write("\n".join(new_lines)) + server.logger.info("MCDR 编码配置已修复") + # 设置标志,以便在命令注册后执行 reload + server._easybot_encoding_fixed = True + else: + server.logger.debug("MCDR decoding 配置正常,无需修改") + + except Exception as e: + server.logger.warning(f"检查 MCDR 编码配置时出错: {e}") + + async def on_load(server: PluginServerInterface, prev_module): """插件加载时执行的函数""" global server_interface, wsc server_interface = server server.logger.info("开始加载EasyBot插件...") - + try: # 加载配置 load_config(server) + # 检查并修复 MCDR 的编码配置 + fix_mcdr_encoding(server) + # 初始化 behavior_impl (PlayerDataGetter 等) from easybot_mcdr import behavior_impl behavior_impl.set_server(server) @@ -104,10 +164,14 @@ async def on_load(server: PluginServerInterface, prev_module): # 注册事件监听器 register_event_listeners(server) - + # 注册命令 register_commands(server) - + + # 如果编码配置被修改,提示用户手动执行 + if getattr(server, '_easybot_encoding_fixed', False): + server.logger.info("编码配置已修改,请手动执行 !!MCDR reload config 使配置生效") + # 检查 RCON 配置 if not server.is_rcon_running(): from easybot_mcdr.rcon_config import check_rcon_config